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

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

+ * + *

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

+ */ +public class ContinuitySample { + + private Form current; + private Resources theme; + + /** The whole of this app's state. Read by the provider, written by the field. */ + private String draft = ""; + + /** Where the field was scrolled to, which is the sort of thing a route cannot carry. */ + private int caret; + + private TextArea field; + private Label status; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + + // Installing a provider is what turns the framework on. Nothing before this line has any + // effect, which is what keeps an app that does not use continuity behaving as it always + // did. + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draft); + state.put("caret", Integer.valueOf(caret)); + return state; + } + + public void restoreState(Map state) { + Object savedDraft = state.get("draft"); + if (savedDraft instanceof String) { + draft = (String) savedDraft; + } + Object savedCaret = state.get("caret"); + // instanceof rather than a cast: a state that crossed from another device came + // through JSON, where every number is a Double, and a failed cast does not throw + // on the iOS virtual machine. + if (savedCaret instanceof Number) { + caret = ((Number) savedCaret).intValue(); + } + } + }); + + // Ask before moving the user. Jumping them somewhere without warning is the wrong default + // for anything they might be midway through, and holding the state is a one-liner. + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(final AppState state) { + String label = state.getTitle() == null ? "your other device" : state.getTitle(); + if (Dialog.show("Continue?", "Pick up \"" + label + "\"?", "Continue", "Stay")) { + Continuity.restore(state); + showDraftForm(); + } + // Consumed either way: the decision has been made here, so no other listener is + // asked and nothing is restored behind this one's back. + return false; + } + }); + + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + refreshStatus(); + } + }); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + // "Restore, or else begin". This app records no routes, so restore() hands the payload to + // the provider and answers false -- the screen is still this app's to show. + Continuity.restore(); + showDraftForm(); + } + + public void stop() { + current = Display.getInstance().getCurrent(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + current = Display.getInstance().getCurrent(); + } + } + + public void destroy() { + } + + private void showDraftForm() { + Form form = new Form("Continuity", BoxLayout.y()); + + field = new TextArea(draft, 5, 40); + field.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + } + }); + form.add(new Label("Type something, then continue it elsewhere:")); + form.add(field); + + status = new Label(""); + form.add(status); + + Button checkpoint = new Button("Save a checkpoint now"); + checkpoint.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + Dialog.show("Saved", "Advertised as \"" + Continuity.getTitle() + "\".", "OK", null); + } + }); + form.add(checkpoint); + + Button remember = new Button("Remember this device's choice"); + remember.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + // A write that reports whether it happened, because the store does not exist on + // most platforms and is finite where it does. + if (!SyncedStore.put("lastEditor", Display.getInstance().getPlatformName())) { + Dialog.show("No synced store", "This platform has none, so the choice stays " + + "on this device.", "OK", null); + } + refreshStatus(); + } + }); + form.add(remember); + + Button forget = new Button("Log out (forget everything)"); + forget.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + draft = ""; + caret = 0; + // The advertised activity outlives this screen, so an account's work would stay + // on offer to the devices around it without this. + Continuity.clear(); + field.setText(""); + refreshStatus(); + } + }); + form.add(forget); + + refreshStatus(); + form.show(); + } + + /** Reads the screen into the fields the provider reports, then checkpoints. */ + private void capture() { + draft = field.getText(); + caret = field.getCursorPosition(); + // A title names the WORK, not the screen: it is what another device shows the user before + // they accept. + Continuity.setTitle(draft.length() == 0 ? "An empty draft" + : "Draft: " + draft.substring(0, Math.min(24, draft.length()))); + Continuity.checkpoint(); + refreshStatus(); + } + + private void refreshStatus() { + if (status == null) { + return; + } + status.setText("continuation: " + (Continuity.isContinuationSupported() ? "yes" : "no") + + " | synced store: " + (SyncedStore.isSupported() ? "yes" : "no") + + " | last editor: " + SyncedStore.get("lastEditor", "none")); + if (status.getComponentForm() != null) { + status.getComponentForm().revalidate(); + } + } +} diff --git a/Samples/samples/ContinuitySample/codenameone_settings.properties b/Samples/samples/ContinuitySample/codenameone_settings.properties new file mode 100644 index 00000000000..d5bb6fec2da --- /dev/null +++ b/Samples/samples/ContinuitySample/codenameone_settings.properties @@ -0,0 +1,9 @@ +#Continuity sample build hints +# Declares that this project hands the user's work between their devices. The build detects the +# reference to com.codename1.continuity on its own; the hint is what lets the Certificate Wizard +# and the signing preflight know whether an iCloud capability will be wanted. +codename1.arg.ios.continuity.enabled=true +# This sample touches com.codename1.continuity.sync, so the build asks for the iCloud key-value +# store entitlement -- which the App ID has to grant. Uncomment to drop it and leave SyncedStore +# reporting itself unsupported; handing work to a nearby device is unaffected either way. +#codename1.arg.ios.continuity.sync=false diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java new file mode 100644 index 00000000000..0065ef289fe --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.continuity; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.ContinuityListener; +import com.codename1.continuity.RestStateRelay; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.continuity.sync.SyncedStoreListener; +import com.codename1.router.Navigation; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; + +import java.util.HashMap; +import java.util.Map; + +/** + * Snippets that accompany the State Restoration and Continuity guide chapter. Each block between + * the tag markers is included verbatim into the AsciiDoc. + */ +public class ContinuitySnippets { + + /** Stands in for the screen the application is showing. */ + private TextArea draftField = new TextArea(); + + /** Stands in for the application's own session object. */ + private Session session = new Session(); + + /** A state the application held back rather than acting on immediately. */ + private AppState held; + + static class Session { + String getAccessToken() { + return "a-token"; + } + } + + // tag::provider[] + public void init(Object context) { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draftField.getText()); + return state; + } + + public void restoreState(Map state) { + draftField.setText((String) state.get("draft")); + } + }); + } + // end::provider[] + + // tag::start[] + public void start() { + if (!Continuity.restore()) { + Navigation.navigate("/home"); + } + } + // end::start[] + + // tag::checkpoint[] + public void onDraftSaved() { + Continuity.setTitle("Draft to Dana"); + Continuity.checkpoint(); + } + // end::checkpoint[] + + // tag::askFirst[] + public void askBeforeMovingTheUser() { + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + held = state; + if (Dialog.show("Continue?", "Pick up \"" + state.getTitle() + + "\" from your other device?", "Continue", "Stay here")) { + Continuity.restore(held); + } + // Consumed either way: the decision has been made here. + return false; + } + }); + } + // end::askFirst[] + + // tag::relay[] + public void useMyOwnEndpoint() { + Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") { + @Override + protected String getToken() { + return session.getAccessToken(); + } + }); + } + // end::relay[] + + // tag::pollOnResume[] + public void onAppResumed() { + Continuity.pollRelay(); + } + // end::pollOnResume[] + + // tag::syncedStore[] + public String readSortOrder() { + return SyncedStore.get("sortOrder", "byName"); + } + + public void writeSortOrder(String order) { + if (!SyncedStore.put("sortOrder", order)) { + // No synced store here, or it is full. The value still has to live somewhere, so + // fall back to this device's own preferences rather than losing the choice. + com.codename1.io.Preferences.set("sortOrder", order); + } + } + // end::syncedStore[] + + // tag::syncedStoreListener[] + public void followTheStore() { + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + // No values are carried, on any platform. Re-read what this screen shows. + applySortOrder(SyncedStore.get("sortOrder", "byName")); + } + }); + } + // end::syncedStoreListener[] + + // tag::capability[] + public void describeWhatThisDeviceCanDo() { + if (Continuity.isContinuationSupported()) { + showBanner("Open this app on your other device to carry on there."); + } + } + // end::capability[] + + // tag::logout[] + public void onLogout() { + Continuity.clear(); + } + // end::logout[] + + // tag::maxAge[] + public void expireACheckout() { + Continuity.setMaxAge(15 * 60 * 1000); + } + // end::maxAge[] + + private void applySortOrder(String order) { + } + + private void showBanner(String message) { + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties new file mode 100644 index 00000000000..60908c86b1f --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties @@ -0,0 +1,9 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::state-restoration-and-continuity-properties-001[] +codename1.arg.ios.continuity.enabled=true +// end::state-restoration-and-continuity-properties-001[] + +// tag::state-restoration-and-continuity-properties-002[] +codename1.arg.ios.continuity.sync=false +// end::state-restoration-and-continuity-properties-002[] diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc new file mode 100644 index 00000000000..89bdfa31932 --- /dev/null +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -0,0 +1,306 @@ +== State Restoration and Continuity + +An app that's put in the background isn't paused. Android reclaims the process +routinely, iOS terminates a suspended app whenever it needs the memory, and in +both cases what comes back isn't the app the user left -- it's a fresh launch +that happens to be wearing the same icon. The user sees their work replaced by +the home screen and has no idea why. + +`com.codename1.continuity` saves what the user was doing and brings it back. On +Apple platforms it does one thing more: it offers that same work to the other +devices the person is signed in to, so a draft begun on the phone can be +finished on the iPad they pick up. + +The two halves cost different things, so they're two packages. +`com.codename1.continuity` holds the framework and everything that carries work +to a device the user is holding. `com.codename1.continuity.sync` holds a small +key/value store the platform keeps in step across their devices, and referencing +it earns an iOS build an entitlement. An app that wants the first shouldn't have +to arrange the second. + +[options="header"] +|=== +| Capability | iOS and macOS | Android | Simulator and desktop | JavaScript +| Restore after the process is killed | yes | yes | yes | yes +| Restore the `@Route` screen stack | yes | yes | yes | yes +| Carry on where they left off, on a device they're holding | yes | -- | simulated | -- +| A key/value store synced across devices | yes (`com.codename1.continuity.sync`) | -- | simulated | -- +| Carry state to any other device | your `StateRelay` | your `StateRelay` | your `StateRelay` | your `StateRelay` +|=== + +Branch on the capability queries -- `Continuity.isSupported()`, +`Continuity.isContinuationSupported()`, `SyncedStore.isSupported()` -- rather +than on platform detection. The first row is the one that matters most and it's +supported everywhere, because it's pure storage with no platform behind it. + +Every callback in this family arrives on the EDT. + +=== Six Things Worth Knowing Before You Design Around This + +*Nothing happens until you ask for it.* An app that never references this package +behaves exactly as it always did, and so does one that references it and never +calls `Continuity.setStateProvider` or `Continuity.enable`. `Continuity.restore()` +is never called for you either. Where restoration belongs in a launch is a +decision only the app can make, and a framework that guessed would be wrong for +the apps that care most. + +*The route stack is free; everything else is yours.* If your screens are declared +with `@Route`, the framework already knows the navigation stack and restores it +with no code from you. If your app navigates with `new MyForm().show()`, those +moves aren't addressable and there's nothing to write down -- so `restore()` +hands your payload to the `StateProvider` and answers `false`, leaving you to +show a screen. Both are supported; only the first is automatic. + +*Saving happens continuously, not at shutdown.* Every navigation marks the state +dirty and a checkpoint is written once per pass of the event loop, so by the time +the operating system suspends the app the work is already done. Don't look for a +place to save on exit; there isn't one worth using. Android blocks its own main +thread until your `stop()` returns, so an app that did its saving there would pay +for it on every suspend. Call `Continuity.checkpoint()` after changing something +your provider reports that no navigation touched. + +*A payload has to survive leaving the device.* It's written to disk, handed to an +operating system, and possibly delivered to a different device running a +different build of your app -- so it admits only `String`, `Integer`, `Long`, +`Double`, `Boolean`, and `List` and `Map` of those. Anything else is refused +where you produced it, with a message naming the key, rather than becoming a +value that stops arriving on the other end with nothing to say so. + +*Codename One runs no relay server.* Continuation between Apple devices is the +platform's; anything else -- iPhone to Android, two devices that are never in the +same room -- goes through a `StateRelay`, which is your endpoint. That isn't a +gap to be filled later. Deciding which saved states belong to the same *person* +is your account system's question, and a framework that answered it would be +guessing about your users. + +*A continuation isn't secure storage.* What you put in the payload crosses to +another device and is held by the operating system on the way. Tokens, keys and +anything you would not want restored on a device that merely shares an account +belong in `com.codename1.security.SecureStorage`, with the payload carrying at +most an identifier that means nothing on its own. + +=== Saving and restoring + +Two pieces. A `StateProvider` supplies the half the framework can't work out -- +the scroll position, the half-typed message, the record being edited -- and +installing one turns the framework on: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=provider,indent=0] +---- + +Then `start()` reads as "restore, or else begin": + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=start,indent=0] +---- + +`restore()` returns `true` when it put a screen up, so the caller knows not to +show its own. It returns `false` when there was nothing to restore *and* when the +state carried no routes -- the payload-only case above -- which is why the +fallback branch belongs there rather than behind a null check. + +`restoreState` runs before the restored screens are built, so a form the route +table is about to construct can read what the provider stashed while that form +is being built. + +Take a checkpoint by hand after a change no navigation followed: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=checkpoint,indent=0] +---- + +The title is what a receiving device may show the user before they accept, so it +should name the work rather than the screen -- `Draft to Dana`, not `Compose`. + +By default a saved state never expires, because an app the user opens after a +month should still come back where they left it. Where coming back is only +meaningful for a while -- a checkout, a booking hold, a queue position -- say so: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=maxAge,indent=0] +---- + +=== Continuing on another device + +Nothing extra is required for the Apple case. Every checkpoint advertises the +current state, and a device the user is holding is offered it by the system. What +you may want is to say so in the interface, which is what the capability query is +for: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=capability,indent=0] +---- + +An arriving state is restored automatically. When moving the user is a decision +your app should make -- they're midway through a payment, or the state belongs +to a different account than the one signed in here -- take it yourself. A +listener that returns `false` has consumed the state: nothing is restored and no +other listener is asked, which is what makes holding it and asking work: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=askFirst,indent=0] +---- + +A state this device published is never offered back to its own listener, and a +state already acted on is never acted on twice -- a continuation and a relay +routinely carry the same one. + +=== Reaching every other device + +A `StateRelay` is your endpoint, and `RestStateRelay` covers the common case: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=relay,indent=0] +---- + +Two requests against the one URL. A `POST` carries the state as a JSON body, +which you store against the signed-in user, replacing whatever you held for them. +A `GET` answers with the newest state you hold for that user, or an empty body +when you hold none. The JSON is a closed shape: your endpoint stores and returns +the document and never needs to look inside it. + +The token comes from `getToken()` rather than from the constructor because it's +read at every request, so a session that refreshes its token is followed with no +further code. + +A relay is written to when the app checkpoints and read only when something asks, +so ask when the app comes back to the foreground: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=pollOnResume,indent=0] +---- + +On Android that call is already made for you when the activity resumes. Making it +yourself as well is harmless -- a state already seen is ignored. + +Put `Continuity.clear()` on your logout path. The advertised activity outlives +your app's own screen, so an account's work would otherwise stay on offer to the +devices around it after the user signed out: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0] +---- + +=== The synced store + +`com.codename1.continuity.sync.SyncedStore` is the slow, patient half: a handful +of durable choices -- which theme, which sort order, which tutorial they already +dismissed -- kept in step across the devices one person is signed in to, without +those devices ever being near each other. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStore,indent=0] +---- + +Note the shape: a read always has a default, and a write reports whether it +happened. That isn't defensive style, it's the API being honest. The store is +empty on a device that has never synced, the user can switch the whole mechanism +off, and it exists on Apple platforms only -- so a synced value with a local +default behind it makes the design work on every platform. + +Changes made elsewhere arrive without values, on every platform that has such a +store at all, so re-read what your screen shows rather than assuming you know +which key moved: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStoreListener,indent=0] +---- + +This isn't storage. It's small, the platform decides when to sync it, and +nothing in it should be anything your app can't do without. + +=== Developing Without Hardware + +The simulator carries a simulated continuity platform, so all this works on +the desktop -- and the `Simulate -> Continuity` menu scripts the cases that are +otherwise reachable only with two devices in your hands. Every item is also +callable from a test with `CN.execute("continuity:itemN")`. + +*Continue Here (As Another Device)* hands whatever the app is currently +advertising straight back to it. That's the whole feature in one click. If it +does nothing, the app hasn't taken a checkpoint yet -- which is itself the +answer to the question of why nothing is being offered. + +The rest reproduce traps rather than the happy path: + +* *Continue A Route This Build Dropped.* A screen goes away in a rebuild and the +states already sitting on the user's other devices still name it. The restore +survives on the frames it can still build. +* *Continue With No Routes (Payload Only).* What an app that doesn't use +`@Route` produces. An app that assumed `restore()` always shows something finds +out here. +* *Continue Something From Yesterday.* Exercises `setMaxAge`, and the listener +that has to decide whether moving the user somewhere they were yesterday is a +courtesy or an ambush. +* *Change The Synced Store Elsewhere.* The notification carries no values, so an +app that re-reads only the key it assumed changed reads a stale one. +* *Make The Synced Store Unsupported* and *Make Continuation Unsupported.* What +every non-Apple platform reports. An app that put a required setting in the +synced store and never checked `isSupported()` loses it here, with no error, +exactly as it would on Android. + +=== Build Hints + +[options="header"] +|=== +| Hint | Default | What it does +| `ios.continuity.enabled` | `false` | Declares that this project hands work between devices. The build works this out from bytecode on its own; this exists because the Certificate Wizard and the signing preflight can't read bytecode and need to know whether an iCloud capability will be wanted. +| `ios.continuity.sync` | `true` | Set `false` to skip the iCloud key-value store entitlement. +|=== + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-001,indent=0] +---- + +Everything else is automatic. Referencing `com.codename1.continuity` compiles the +`NSUserActivity` handling into the iOS build and declares this app's activity +type in `NSUserActivityTypes`, which is what lets another device be offered the +work -- iOS continues an activity only when the app declared its type, so an app +that skipped this would publish states nobody is ever shown. Referencing +`com.codename1.continuity.sync` additionally asks for the +`com.apple.developer.ubiquity-kvstore-identifier` entitlement. Apps that touch +neither package get none of it, on any platform. Android needs nothing injected +at all: no permission, no manifest entry, no dependency. + +The activity type is your package name followed by `.continuity`, derived the +same way in the build and at runtime, so there's nothing to configure and +nothing to get out of step. `Continuity.getActivityType()` returns it, which is +the first thing to check when a continuation never arrives. + +That entitlement is the one part of this that can stop a build. Apple grants it +only through an App ID with the iCloud capability enabled, so a profile issued +before that was switched on matches your bundle id and authorizes none +of it -- and the build fails at codesigning, talking about an entitlement rather +than about the capability. Codename One checks the profile before sending the +build and warns, naming both ways out: enable iCloud on the App ID and regenerate +the profile, or drop the entitlement. + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-002,indent=0] +---- + +With that set, `SyncedStore.isSupported()` reports `false` at runtime and the +rest of the app is unaffected. Handing work to a nearby device needs no +entitlement and keeps working either way. + +WARNING: A restored state is restored on a device that has the app, not +necessarily on the device that saved it and not necessarily by the person who +did. Treat the payload as a description of *what screen to show*, never as proof +of who is looking at it: re-check the signed-in account after restoring, and put +nothing in a payload that would be a disclosure if it appeared on a family +member's iPad. This is also what makes `Continuity.clear()` on logout more than +housekeeping. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 1b61a722511..8bab7094511 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -149,6 +149,8 @@ include::Printing.asciidoc[] include::Deep-Links-Routing.asciidoc[] +include::State-Restoration-And-Continuity.asciidoc[] + include::App-Intents.asciidoc[] include::Document-Provider.asciidoc[] diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 51e608299b8..f38f7e6445a 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -464,6 +464,15 @@ "SurfacesTimelineLogicTest" ] }, + { + "id": "state-restoration-continuity", + "category": "System surfaces", + "name": "State restoration and continuity", + "description": "Saves and restores what the user was doing, and checks the parts that have to behave identically on every port: the codec both wire formats share, the payload rule that lets a state cross to another device, the checkpoint, and the routeless restore that hands its payload back and shows nothing.", + "tests": [ + "ContinuityStateTest" + ] + }, { "id": "document-provider", "category": "System surfaces", diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 29a3d695493..6e464a0d87a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -792,6 +792,30 @@ static void register(List h) { + "group, no plist keys -- leaving com.codename1.documents an inert no-op " + "at runtime.")); + h.add(new Hint("ios.continuity.enabled") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("false") + .platform("ios") + .doc("Declares that this project hands the user's work between their devices. " + + "The build detects a reference to com.codename1.continuity on its own, " + + "so this is redundant for the build itself; it exists because the " + + "Certificate Wizard and the signing preflight work without reading " + + "bytecode and need to know whether an iCloud capability will be " + + "wanted.")); + + h.add(new Hint("ios.continuity.sync") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Set false to skip the iCloud key-value store entitlement that a reference " + + "to com.codename1.continuity.sync would otherwise earn. Use it when the " + + "App ID has no iCloud capability and the app can live without a synced " + + "store: SyncedStore then reports itself unsupported at runtime instead " + + "of the build failing to sign. Handing work to a nearby device is " + + "unaffected -- that half needs no entitlement.")); + h.add(new Hint("ios.superfastBuild") .group(HintGroup.IOS) .type(HintType.BOOLEAN) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 66da695f342..55a3fcc1d3d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1304,6 +1304,21 @@ private java.util.Set foldInCallAndVpnLibraryUsage( // extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime. private boolean surfacesExtensionEnabled; + // Set when the app references com.codename1.continuity. Gates the CN1_USE_CONTINUITY native + // define and this app's entry in NSUserActivityTypes -- which is all continuation costs, + // there being no entitlement behind NSUserActivity handoff. + // + // Note what this does NOT gate: saving and restoring state on the device is pure Java over + // com.codename1.io.Storage and works in every build. Only the cross-device half is here. + private boolean usesContinuity; + + // Set when the app references com.codename1.continuity.sync -- deliberately narrower than + // usesContinuity, and for the reason usesHomeAccessoryData is narrower than usesSmartHome. + // The synced store is NSUbiquitousKeyValueStore, whose entitlement has to be granted on the + // App ID, so handing it to an app that only wanted to pass work to the tablet in the user's + // other hand would fail its codesigning for a capability it never asked for. + private boolean usesContinuitySync; + // Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native // define, the CN1Documents file provider extension and the app group that lets the two // processes meet. @@ -2655,6 +2670,20 @@ public void usesClass(String cls) { if (!usesDocuments && cls.indexOf("com/codename1/documents/") == 0) { usesDocuments = true; } + // State restoration and continuity (com.codename1.continuity.*). Gated on + // actual usage so the CN1_USE_CONTINUITY natives and the NSUserActivityTypes + // entry are only added for apps that hand work between devices. + if (!usesContinuity && cls.indexOf("com/codename1/continuity/") == 0) { + usesContinuity = true; + } + // The synced store, which is the only half that costs an entitlement. Its own + // package, so this prefix is a strict extension of the one above and both + // flags are set for an app that uses it -- which is correct: the store needs + // the native define too. + if (!usesContinuitySync + && cls.indexOf("com/codename1/continuity/sync/") == 0) { + usesContinuitySync = true; + } // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage // so WatchConnectivity.framework and the CN1_USE_WATCHCONNECTIVITY // natives are only added for apps that talk to their watch app. @@ -4099,6 +4128,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 @@ -5453,6 +5495,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 @@ -11601,6 +11675,21 @@ static String withSpotlightContinuation(String inject, boolean usesIntents) { /// `IOSAppIntentsBuilder.publishesUserActivity` for why advertising the rest is not /// harmlessly generous. static String userActivityTypesKey(List> intents) { + return userActivityTypesKey(intents, null); + } + + /// The same key, carrying the continuity activity type alongside the intent ids. + /// + /// One key, not two. `NSUserActivityTypes` appears once in a property list or iOS reads the + /// file unpredictably, so the two features that contribute to it -- app intents and + /// continuity -- have to meet here rather than each emitting their own. An app that uses both + /// is the ordinary case, not a corner. + /// + /// #### Parameters + /// + /// - `intents`: the app's intent declarations, possibly empty + /// - `continuityType`: this app's continuity activity type, or null when it uses none + static String userActivityTypesKey(List> intents, String continuityType) { StringBuilder types = new StringBuilder(); for (Map intent : intents) { Object id = intent.get("id"); @@ -11608,6 +11697,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 @@ -11619,6 +11711,18 @@ static String userActivityTypesKey(List> intents) { } 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 @@ -11641,6 +11745,10 @@ static String mergeUserActivityTypes(String inject, List> in add.append("").append((String) id).append(""); } } + if (continuityType != null && continuityType.length() > 0 + && !existing.contains("" + continuityType + "")) { + add.append("").append(continuityType).append(""); + } if (add.length() == 0) { return inject; } @@ -14178,19 +14286,26 @@ 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); + 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); + inject = mergeUserActivityTypes(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/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..bdf32d8f3fd 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,75 @@ static List check(Properties settings, boolean release, Date now) { return problems; } + /** + * Whether the profile can sign an app that asks for the iCloud key-value store. + * + *

A reference to {@code com.codename1.continuity.sync} makes the build declare + * {@code com.apple.developer.ubiquity-kvstore-identifier}, and Apple grants that entitlement + * only through an App ID with the iCloud capability enabled. A profile issued before that was + * switched on matches the bundle id perfectly and still authorizes none of it, so the build + * runs all the way to codesign and fails there -- talking about an entitlement rather than + * about the iCloud capability nobody enabled.

+ * + *

Never fatal, and that is deliberate: unlike the App Group checks beside it, this + * entitlement has a documented opt-out. {@code ios.continuity.sync=false} drops it and leaves + * the app working with {@code SyncedStore.isSupported()} reporting false, so a warning that + * names the two ways out is more useful than a refusal.

+ * + * @return one problem when the profile demonstrably lacks the entitlement, none when it has + * it, the sync half is switched off, or nothing readable says either way + */ + static List checkContinuitySync(Properties settings, boolean release) { + List problems = new ArrayList(); + if (settings == null) { + return problems; + } + if (!"true".equals(trimmed(settings.getProperty( + "codename1.arg.ios.continuity.enabled")))) { + // The project has not said it wants a synced store. The builder decides this from + // bytecode, which this check cannot read -- so an app that uses the API without + // setting the hint is simply not checked here, and finds out at codesign as it does + // today. Guessing from anything else would warn projects that use no continuity at + // all. + return problems; + } + if ("false".equals(trimmed(settings.getProperty( + "codename1.arg.ios.continuity.sync")))) { + // Explicitly opted out: the build declares no entitlement, so there is nothing the + // profile has to grant. + return problems; + } + String override = trimmed(settings.getProperty("codename1.arg.ios.entitlements.com.apple" + + ".developer.ubiquity-kvstore-identifier")); + if (override != null && !override.isEmpty()) { + // The project named a container of its own, which is the shape of an app sharing a + // store with a sibling. Whether the profile grants that particular one is a question + // this cannot answer from the key alone, and warning on it would be noise. + return problems; + } + Profile appProfile = appProfile(settings, release); + if (appProfile == null || appProfile.applicationIdentifier == null) { + // No readable profile: check() reports that, and it is not something to warn about + // twice. + return problems; + } + if (appProfile.ubiquityKeyValueStore) { + return problems; + } + problems.add(new Problem("This app uses com.codename1.continuity.sync, so the build asks " + + "for the iCloud key-value store entitlement " + + "(com.apple.developer.ubiquity-kvstore-identifier) -- and the provisioning " + + "profile \"" + appProfile.name + "\" does not grant it.\n" + + "Apple grants it only through an App ID with the iCloud capability enabled, so " + + "signing will fail on the entitlement rather than on the profile name.\n" + + "Either enable iCloud on the App ID at developer.apple.com and regenerate the " + + "profile, or set codename1.arg.ios.continuity.sync=false -- which drops the " + + "entitlement and leaves SyncedStore reporting itself unsupported at runtime. " + + "Handing work to a nearby device is unaffected either way; that half needs no " + + "entitlement.", false)); + return problems; + } + /** * Whether every app extension this build embeds can actually be signed. * @@ -1005,6 +1082,11 @@ static Profile parse(byte[] raw) throws Exception { } } } + // Same nesting again: this is what says whether the profile can sign a target that asks + // for the iCloud key-value store, which a reference to com.codename1.continuity.sync + // makes the build declare. + Element ubiquity = valueForKey(doc, "com.apple.developer.ubiquity-kvstore-identifier"); + profile.ubiquityKeyValueStore = ubiquity != null; profile.type = deriveType(doc); return profile; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java new file mode 100644 index 00000000000..280226de9f8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code NSUserActivityTypes} has two contributors, and there can only be one key. + * + *

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

+ * + *

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

+ */ +class IPhoneBuilderContinuityPlistTest { + + private static final String CONTINUITY_TYPE = "com.example.app.continuity"; + + private static Map intent(String id) { + Map m = new HashMap(); + m.put("id", id); + m.put("assistant", Boolean.TRUE); + return m; + } + + private static List> intents(String... ids) { + List> out = new ArrayList>(); + for (String id : ids) { + out.add(intent(id)); + } + return out; + } + + private static List> noIntents() { + return new ArrayList>(); + } + + private static int occurrences(String haystack, String needle) { + int count = 0; + int at = haystack.indexOf(needle); + while (at >= 0) { + count++; + at = haystack.indexOf(needle, at + needle.length()); + } + return count; + } + + // ------------------------------------------------------------------ + // Emitting the key + // ------------------------------------------------------------------ + + @Test + void continuityAloneDeclaresItsActivityType() { + String key = IPhoneBuilder.userActivityTypesKey(noIntents(), CONTINUITY_TYPE); + + assertTrue(key.contains("NSUserActivityTypes"), key); + assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key); + assertEquals(1, occurrences(key, "NSUserActivityTypes"), key); + } + + @Test + void intentsAndContinuityShareOneKey() { + String key = IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), CONTINUITY_TYPE); + + assertEquals(1, occurrences(key, "NSUserActivityTypes"), key); + assertEquals(1, occurrences(key, ""), key); + assertTrue(key.contains("logWorkout"), key); + assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key); + } + + @Test + void intentsAloneAreUnchangedByTheContinuityParameter() { + assertEquals(IPhoneBuilder.userActivityTypesKey(intents("logWorkout")), + IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), null)); + } + + /** + * An app with nothing to declare writes nothing. An empty array would state that the app + * continues no activity at all, into the plist of an app that may well continue its own. + */ + @Test + void nothingToDeclareWritesNoKey() { + assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), null)); + assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), "")); + } + + // ------------------------------------------------------------------ + // Merging into an array the application supplied + // ------------------------------------------------------------------ + + @Test + void continuityMergesIntoAnArrayTheApplicationDeclared() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + assertTrue(merged.contains("com.example.app.legacyHandoff"), merged); + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + } + + @Test + void intentsAndContinuityBothMergeIntoOneSuppliedArray() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), + CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + assertEquals(1, occurrences(merged, ""), merged); + assertTrue(merged.contains("com.example.app.legacyHandoff"), merged); + assertTrue(merged.contains("logWorkout"), merged); + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + } + + /** + * A project that already named the continuity type itself gets it once, not twice. + */ + @Test + void anAlreadyDeclaredContinuityTypeIsNotDuplicated() { + String inject = "NSUserActivityTypes" + + "" + CONTINUITY_TYPE + ""; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "" + CONTINUITY_TYPE + ""), merged); + } + + @Test + void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() { + String inject = "NSUserActivityTypesnot an array"; + + assertEquals(inject, + IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE)); + } + + /** + * The parser has to accept the shapes a hand-written fragment really carries. + */ + @Test + void aSpacedClosingTagIsStillMergedInto() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + } + + @Test + void nothingToAddLeavesTheFragmentAlone() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null)); + assertFalse(IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null) + .contains("null")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java new file mode 100644 index 00000000000..137f7df1522 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * An app that uses {@code com.codename1.continuity.sync} asks for the iCloud key-value store + * entitlement, and Apple grants that only through an App ID with the iCloud capability enabled. + * + *

A profile issued before that was switched on matches the bundle id perfectly and authorizes + * none of it, so the build runs all the way to codesign and fails there -- talking about an + * entitlement rather than about the capability nobody enabled. Everything needed to say so is on + * disk before the build is sent.

+ * + *

Warned about rather than refused, unlike the App Group checks beside it: this entitlement has + * a documented opt-out ({@code ios.continuity.sync=false}) that leaves the app working, so naming + * the two ways out is more useful than a refusal.

+ */ +public class IOSContinuitySyncPreflightTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String HEAD = "\n" + + "\n" + + "\n"; + + /// @param ubiquity true for a profile issued from an App ID with iCloud enabled + private File profile(String name, boolean ubiquity) throws Exception { + String kvStore = ubiquity + ? "com.apple.developer.ubiquity-kvstore-identifier" + + "ABCD1234.com.example.app" + : ""; + String plist = HEAD + + "Name" + name + "\n" + + "UUID0f7ac3c1-4d0e-4e8a-9d1f-8b6a2c5e7d90\n" + + "ExpirationDate2099-01-01T00:00:00Z\n" + + "DeveloperCertificatesZm9v\n" + + "Entitlements" + + "application-identifier" + + "ABCD1234.com.example.app" + + kvStore + + "get-task-allow\n" + + ""; + byte[] payload = plist.getBytes("UTF-8"); + // The parser skips a binary preamble, exactly as a real signed profile carries one. + byte[] wrapped = new byte[payload.length + 24]; + for (int i = 0; i < 16; i++) { + wrapped[i] = (byte) (0x80 + i); + } + System.arraycopy(payload, 0, wrapped, 16, payload.length); + File f = tmp.newFile(name + ".mobileprovision"); + OutputStream out = new FileOutputStream(f); + try { + out.write(wrapped); + } finally { + out.close(); + } + return f; + } + + private Properties settings(File appProfile) throws Exception { + Properties p = new Properties(); + p.setProperty("codename1.packageName", "com.example.app"); + p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true), + appProfile.getAbsolutePath()); + p.setProperty("codename1.arg.ios.continuity.enabled", "true"); + return p; + } + + private static List check(Properties p) { + return IOSProvisioningPreflight.checkContinuitySync(p, true); + } + + @Test + public void aProfileWithoutTheEntitlementIsWarnedAbout() throws Exception { + List problems = check(settings(profile("NoCloud", false))); + + assertEquals(1, problems.size()); + assertTrue(problems.get(0).message.contains("ubiquity-kvstore-identifier")); + // Both ways out are named, because either is a legitimate answer. + assertTrue(problems.get(0).message.contains("iCloud")); + assertTrue(problems.get(0).message.contains("ios.continuity.sync=false")); + assertFalse("a documented opt-out exists, so this must not refuse the build", + problems.get(0).fatal); + } + + @Test + public void aProfileWithTheEntitlementPassesQuietly() throws Exception { + assertTrue(check(settings(profile("WithCloud", true))).isEmpty()); + } + + @Test + public void theOptOutSkipsTheCheckEntirely() throws Exception { + Properties p = settings(profile("NoCloud", false)); + p.setProperty("codename1.arg.ios.continuity.sync", "false"); + + assertTrue(check(p).isEmpty()); + } + + /** + * A project that has not said it uses continuity is not checked. The builder decides that + * from bytecode, which this cannot read -- and guessing from anything else would warn + * projects that use none of it. + */ + @Test + public void aProjectThatDeclaresNoContinuityIsNotChecked() throws Exception { + Properties p = settings(profile("NoCloud", false)); + p.remove("codename1.arg.ios.continuity.enabled"); + + assertTrue(check(p).isEmpty()); + } + + /** + * An app sharing a store with a sibling names that sibling's container. Whether the profile + * grants that particular one is not a question this can answer from the key alone. + */ + @Test + public void anExplicitContainerIsLeftAlone() throws Exception { + Properties p = settings(profile("NoCloud", false)); + p.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared"); + + assertTrue(check(p).isEmpty()); + } + + /** No readable profile is reported by check(), and is not something to warn about twice. */ + @Test + public void anUnreadableProfileIsLeftToTheOtherChecks() throws Exception { + Properties p = new Properties(); + p.setProperty("codename1.packageName", "com.example.app"); + p.setProperty("codename1.arg.ios.continuity.enabled", "true"); + p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true), + "/nowhere/missing.mobileprovision"); + + assertTrue(check(p).isEmpty()); + } + + @Test + public void nullSettingsProduceNoProblems() { + assertTrue(IOSProvisioningPreflight.checkContinuitySync(null, true).isEmpty()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java new file mode 100644 index 00000000000..b04d59aa067 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.io.Util; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The two wire formats an {@link AppState} has to survive, and the payload rule that makes both + * possible. + * + *

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

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

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

+ */ + @Test + public void aMillisecondTimestampSurvivesJsonExactly() throws Exception { + AppState state = new AppState().setTimestamp(1763512345678L).setSequence(9007199254740993L); + + AppState back = StateCodec.fromJson(StateCodec.toJson(state)); + + assertEquals(1763512345678L, back.getTimestamp()); + assertEquals(9007199254740993L, back.getSequence()); + } + + @Test + public void externalizableRoundTripPreservesEveryField() throws Exception { + Util.register(AppState.OBJECT_ID, AppState.class); + AppState state = sample(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + Util.writeObject(state, out); + out.close(); + Object read = Util.readObject( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + + assertTrue(read instanceof AppState); + AppState back = (AppState) read; + assertEquals(Arrays.asList("/home", "/users/42"), back.getRoutes()); + assertEquals("Ada", back.getPayload().get("name")); + assertEquals("device-a", back.getDeviceId()); + assertEquals("Editing Ada", back.getTitle()); + assertEquals(7L, back.getSequence()); + assertEquals(1700000000123L, back.getTimestamp()); + } + + @Test + public void aNestedPayloadSurvivesTheMapForm() { + Map inner = new HashMap(); + inner.put("street", "Sesame"); + List 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)); + } + + @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); + } +} 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..3295fcc53c0 --- /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 void 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 void 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..e22db1f6a9e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -0,0 +1,504 @@ +/* + * 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.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(); + 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")); + } + + // ------------------------------------------------------------------ + // 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")); + } + + @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 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..f41db7a2e84 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java @@ -0,0 +1,236 @@ +/* + * 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(); + 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); + } + + @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 effff97ceb6..ccae138a75a 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -535,6 +535,12 @@ private static int testTimeoutMs(BaseTest testClass) { // at all is also what makes the iOS extension target and the Android get // generated and compiled in the first place. new DocumentProviderPublishTest(), + // State restoration and continuity on the device VM: the codec both wire formats + // share, the payload rule, the checkpoint and the restore. Referencing + // com.codename1.continuity at all is also what makes the iOS build compile the + // NSUserActivity natives and declare this app's activity type in the plist. + new ContinuityStateTest(), + // App intents on the device VM: the generated registry, the coercion it wraps // every parameter in, and entity resolution behind an id. The declarations it // exercises are also what make the iOS Swift and the Android shortcut resources diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java new file mode 100644 index 00000000000..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..3ee8a310d0d 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.enabled=true` | Declares the feature. Redundant for the build, which detects the API reference itself, but it is how the Certificate Wizard and the signing preflight know an iCloud capability will be wanted. | +| `ios.continuity.sync=false` | Skip the iCloud key-value store entitlement a reference to `com.codename1.continuity.sync` earns, leaving `SyncedStore` unsupported at runtime. | + +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 | From ae68340554e946dd69a7f0008483d65e57c3877e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:38:06 +0300 Subject: [PATCH 002/140] Record why continuity needs no cn1lib scan, at the line that invites one A review asked for a LibraryClassPrefixScan fold-in over buildinRes, on the premise that a cn1lib using only continuity would be invisible to the class scan. The premise is false: 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 a cn1lib's classes reach the server merged into the application's own and are walked by this scan. The fix would also have been actively harmful rather than merely redundant. Navigation calls Continuity.routeStackChanged, so the framework's own classes name this package, and LibraryClassPrefixScan filters only classes INSIDE the scanned prefix -- a fold-in would have reported continuity usage for every application ever built and demanded an iCloud entitlement that fails codesigning wherever the App ID never enabled it. Comment rather than a reply, because the next reader is in the file and not in the thread. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/codename1/builders/IPhoneBuilder.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 55a3fcc1d3d..0bb26a26b66 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 @@ -2673,6 +2673,17 @@ public void usesClass(String cls) { // 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; } From a19eaa52b1744ad641dc1603d377f4a6a7fb4ef5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:31:29 +0300 Subject: [PATCH 003/140] Address the continuity review: wire format, ordering, threading, lifecycle Ten findings, each with a test that fails without its fix. The sharpest was measured rather than taken on trust, and is worse than it read: JSONParser returns every JSON number as a Double and `true` as the STRING "true", so a payload crossing the relay or an NSUserActivity came back with different types than it left with -- Integer 3 as 3.0, Boolean.TRUE as "true", and Long 9007199254740993 as 9.007199254740992E15, a different number. An application casting back what it stored got a ClassCastException on Android and the desktop and, on iOS, silent corruption: ParparVM does not throw for a failed cast, it hands the wrong object to the next instruction. Payload scalars now cross as tagged strings and are rebuilt on arrival; strings are tagged too, so an application's own "i:5" is still a string. Null is no longer admitted. A property list cannot carry one: the iOS sanitizer dropped a null-valued entry and dropped a null LIST ELEMENT, shifting every index after it, so what arrived on the other device was a different shape from what was sent. Refused where the key is known instead. Relay publishes are serialized and coalesced behind one worker. A thread per checkpoint raced, and because a publish REPLACES the stored document the slower older request could land last -- leaving the user's other device fetching work they had already moved past, with nothing logged. The test drives six checkpoints through a slow relay; before the fix it observed [2, 5, 4, 3, 7, 6]. maxAge now applies to states arriving from elsewhere and to a parked one. A relay hands back whatever it still holds, so an expired checkout could auto-restore -- the exact harm the knob exists to prevent. Dropping an expired state deliberately does not consume its sequence, or a fresher state from the same device would look like one already seen. The Android suspend checkpoint runs on the event thread. onSaveInstanceState is Android's main thread, and StateProvider.saveState is documented as EDT code captured beside an EDT-owned route stack. Gated on a pending checkpoint first, so the ordinary suspend -- where write-through already ran -- still costs no thread hop, and bounded so a wedged event thread cannot turn a missed checkpoint into an ANR. The legacy delegate now compiles for continuity. application:continueUserActivity: was guarded on universal links or intents alone, so an ios.uiscene=false build using only continuity had the branch compiled and nothing to call it. Verified by preprocessing the generated project with intents off: the entry point appears only with this change, and the file compiles clean in both configurations. An AppState is now a snapshot. setPayload copied only the outer map, so nested lists stayed shared with the application and could be edited while the relay serialized them on a background thread. capture() persists the sequence it allocates. Only checkpoint() did, so an application using the documented capture() for its own transport restarted lower and had its states silently ignored by a receiver still holding the old mark. The signing preflight keys on the sync declaration rather than on a general "uses continuity" one, which warned projects about an entitlement their build was never going to request. That hint then had no reader at all, so it is gone rather than left inert. Two findings are answered in code rather than followed: - Payload-only restore keeps returning false. Returning true would leave an application whose provider only populates fields -- the shape the guide recommends -- on no screen at all. StateProvider's javadoc was the piece that disagreed with the guide, the sample and the test; it is corrected. - The cn1lib scan, answered in the previous commit. Also fixed under the project's PMD gate, which forbids volatile: the cross-thread fields are behind a lock, as CodenameOneImplementation already does it. Converting them surfaced two that were genuinely wrong -- `dirty` is read from Android's main thread, and `waitingForWindow` is cleared by the waiter thread. Removing an identity comparison in the same pass fixed a third bug: a newer state arriving while the waiter slept was being discarded in favour of the one it was started for. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 70 ++-- .../com/codename1/continuity/Continuity.java | 302 ++++++++++++++---- .../codename1/continuity/RestStateRelay.java | 2 + .../com/codename1/continuity/StateCodec.java | 164 +++++++++- .../codename1/continuity/StateProvider.java | 20 +- .../continuity/sync/SyncedStore.java | 10 +- .../continuity/LocalContinuityBridge.java | 14 +- .../src/com/codename1/router/Navigation.java | 3 +- .../continuity/AndroidContinuityBridge.java | 51 ++- .../nativeSources/CodenameOne_GLAppDelegate.m | 11 +- .../impl/ios/IOSContinuityBridge.java | 9 + .../codenameone_settings.properties | 6 +- ...tate-restoration-and-continuity.properties | 4 - .../State-Restoration-And-Continuity.asciidoc | 10 +- .../codename1/build/shared/BuildHintsIos.java | 27 +- .../com/codename1/builders/IPhoneBuilder.java | 62 +++- .../maven/IOSProvisioningPreflight.java | 29 +- .../IPhoneBuilderContinuityPlistTest.java | 85 +++++ .../maven/IOSContinuitySyncPreflightTest.java | 28 +- .../continuity/AppStateWireTest.java | 135 ++++++++ .../continuity/LocalContinuityTest.java | 127 ++++++++ .../resources/skill/references/build-hints.md | 2 +- 22 files changed, 994 insertions(+), 177 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java index a9d280aff8b..c9472fb516e 100644 --- a/CodenameOne/src/com/codename1/continuity/AppState.java +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -31,7 +31,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; @@ -72,12 +71,6 @@ public final class AppState implements Externalizable { private long sequence; private long timestamp; - /// Creates an empty state. Applications normally obtain one from - /// `Continuity.getRestorableState()` or receive one through a `ContinuityListener`; this is - /// public so tests and relays can build one. - public AppState() { - } - /// The navigation stack as route paths, oldest first. Never null, possibly empty. /// /// #### Returns @@ -99,8 +92,7 @@ public List getRoutes() { public AppState setRoutes(List r) { routes = new ArrayList(); if (r != null) { - for (Iterator i = r.iterator(); i.hasNext();) { - String path = i.next(); + for (String path : r) { if (path != null && path.length() > 0) { routes.add(path); } @@ -133,10 +125,7 @@ public Map getPayload() { /// - `IllegalArgumentException`: when a value cannot cross to another device public AppState setPayload(Map p) { StateCodec.requireRepresentable(p); - payload = new HashMap(); - if (p != null) { - payload.putAll(p); - } + payload = deepCopy(p); return this; } @@ -149,10 +138,51 @@ public AppState setPayload(Map p) { /// /// - `p`: the payload; null is treated as empty void setPayloadUnchecked(Map p) { - payload = new HashMap(); - if (p != null) { - payload.putAll(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 @@ -291,8 +321,8 @@ public void externalize(DataOutputStream out) throws IOException { out.writeLong(sequence); out.writeLong(timestamp); out.writeInt(routes.size()); - for (Iterator i = routes.iterator(); i.hasNext();) { - Util.writeUTF(i.next(), out); + 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 @@ -321,9 +351,7 @@ public void internalize(int version, DataInputStream in) throws IOException { payload = new HashMap(); if (p instanceof Map) { Map read = (Map) p; - for (Iterator> i = read.entrySet().iterator(); - i.hasNext();) { - Map.Entry entry = i.next(); + 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 index 642afa40107..5a11dcc59c0 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -33,7 +33,6 @@ import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; @@ -123,25 +122,43 @@ public final class Continuity { /// routinely, since a continuation and a relay can carry the same one -- acts once. private static final Map lastSeen = new HashMap(); - // The fields below `bridgeOverridden` are volatile because a port delivers a continuation on - // whatever thread the platform hands it over on -- on Apple platforms that is the main thread, - // not the event dispatch thread -- while the application configures them from its own. The - // ones that stay plain (`dirty`, `flushScheduled`, `sequence`) are touched only from the EDT, - // by routeStackChanged and by the checkpoint it schedules. - private static volatile StateProvider provider; - private static volatile StateRelay relay; - private static volatile ContinuityBridge bridge; - private static volatile boolean bridgeOverridden; - private static volatile boolean enabled; - private static volatile boolean autoRestore = true; - private static boolean dirty; + // Configured by the application while it starts, then read. A lock rather than volatile + // fields, which the project's PMD gate forbids and which would be the wrong tool anyway for + // the two below 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; - private static volatile boolean waitingForWindow; - private static volatile String deviceId; - private static volatile String title; + private static String title; private static long sequence; - private static volatile long maxAge; - private static volatile AppState parked; + private static long maxAge; + + /// Guards the two fields a port can touch from a thread of its own. + /// + /// A continuation arrives on whatever thread the platform hands it over on -- on Apple + /// platforms the main thread, not the event dispatch thread -- so `parked` and `deviceId` are + /// written from there and read on the EDT. `HANDOFF_LOCK` is never held while application + /// code runs, so it cannot be part of a deadlock. + private static final Object HANDOFF_LOCK = new Object(); + + /// The device id, lazily created. Guarded by HANDOFF_LOCK. + private static String deviceId; + + /// Whether a checkpoint is owed. Guarded by HANDOFF_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 HANDOFF_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 HANDOFF_LOCK. + private static AppState parked; private Continuity() { } @@ -184,7 +201,9 @@ public static void disable() { return; } enabled = false; - dirty = false; + synchronized (HANDOFF_LOCK) { + dirty = false; + } clearContinuation(); } @@ -367,10 +386,12 @@ public static long getMaxAge() { /// /// the device id, never null public static String getDeviceId() { - if (deviceId == null) { - deviceId = loadDeviceId(); + synchronized (HANDOFF_LOCK) { + if (deviceId == null) { + deviceId = loadDeviceId(); + } + return deviceId; } - return deviceId; } // ------------------------------------------------------------------ @@ -384,15 +405,18 @@ public static void routeStackChanged() { if (!enabled) { return; } - dirty = true; + synchronized (HANDOFF_LOCK) { + dirty = true; + } if (flushScheduled || !Display.isInitialized()) { return; } flushScheduled = true; Display.getInstance().callSerially(new Runnable() { + @Override public void run() { flushScheduled = false; - if (dirty) { + if (isCheckpointPending()) { checkpoint(); } } @@ -413,7 +437,9 @@ public static void checkpoint() { if (!enabled) { return; } - dirty = false; + synchronized (HANDOFF_LOCK) { + dirty = false; + } AppState state = capture(); if (state == null) { return; @@ -423,8 +449,27 @@ public static void checkpoint() { publishToRelay(state); } - /// Builds a state from the route stack and the provider without writing it anywhere. Useful - /// for sending one somewhere of your own. + /// 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 (HANDOFF_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 /// @@ -458,6 +503,12 @@ public static AppState capture() { } } sequence = nextSequence(); + // 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(); state.setDeviceId(getDeviceId()) .setSequence(sequence) .setTimestamp(System.currentTimeMillis()) @@ -476,20 +527,37 @@ public static AppState capture() { /// /// the state, or null when there is nothing to restore or it is older than `getMaxAge()` public static AppState getRestorableState() { - if (parked != null) { - return parked; + AppState waiting; + synchronized (HANDOFF_LOCK) { + waiting = parked; } - AppState stored = readStored(); - if (stored == null) { - return null; + 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)) { + setParked(null); + return null; + } + return waiting; } - if (maxAge > 0 && stored.getTimestamp() > 0 - && System.currentTimeMillis() - stored.getTimestamp() > maxAge) { + 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) { + return maxAge > 0 && state.getTimestamp() > 0 + && System.currentTimeMillis() - state.getTimestamp() > maxAge; + } + /// Restores whatever `getRestorableState()` offers. /// /// Written to read as "restore, or else begin": @@ -510,7 +578,7 @@ public static boolean restore() { if (state == null) { return false; } - parked = null; + setParked(null); return restore(state); } @@ -543,8 +611,14 @@ public static boolean restore(AppState state) { List routes = state.getRoutes(); if (routes.isEmpty()) { // Payload-only restoration, which is what an app that does not use @Route gets. The - // provider was given everything there is; whether that produced a form is its - // business, and saying "no form" here would make the caller show a second one. + // 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; } try { @@ -566,6 +640,7 @@ public static void pollRelay() { return; } Display.getInstance().startThread(new Runnable() { + @Override public void run() { AppState fetched = null; try { @@ -588,8 +663,10 @@ public void run() { /// account's work would otherwise stay offered to the devices around it after the user signed /// out. public static void clear() { - parked = null; - dirty = false; + setParked(null); + synchronized (HANDOFF_LOCK) { + dirty = false; + } lastSeen.clear(); clearContinuation(); try { @@ -618,8 +695,8 @@ private static List currentRoutes() { Log.e(t); return paths; } - for (int i = 0; i < stack.size(); i++) { - paths.add(stack.get(i).getPath()); + for (com.codename1.router.NavigationEntry entry : stack) { + paths.add(entry.getPath()); } return paths; } @@ -627,6 +704,14 @@ private static List currentRoutes() { 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() { + try { Preferences.set(PREF_SEQUENCE, sequence); } catch (Throwable t) { Log.e(t); @@ -685,21 +770,72 @@ private static void clearContinuation() { } } + /// 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 PUBLISH_LOCK. + private static boolean publishing; + + private static final Object PUBLISH_LOCK = new Object(); + + /// 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) { - final StateRelay r = relay; - if (r == null || !Display.isInitialized()) { + if (relay == null || !Display.isInitialized()) { return; } - final AppState captured = state; + synchronized (PUBLISH_LOCK) { + pendingPublish = state; + if (publishing) { + // The live publisher will pick this up when it finishes its current request, + // which is what makes the ordering total. + return; + } + publishing = true; + } Display.getInstance().startThread(new Runnable() { + @Override public void run() { try { - r.publish(captured); - } catch (Throwable t) { - // Logged and dropped. The state is already in storage, and the next - // checkpoint carries a superset of it, so retrying this one would only put an - // older state on the wire after a newer one. - Log.e(t); + for (;;) { + StateRelay r = relay; + AppState next; + synchronized (PUBLISH_LOCK) { + next = pendingPublish; + pendingPublish = null; + } + if (r == null || next == null) { + return; + } + try { + r.publish(next); + } catch (Throwable t) { + // Logged and dropped. The state is already in storage, and the next + // checkpoint carries a superset of it, so retrying this one would put + // an older state on the wire after a newer one. + Log.e(t); + } + } + } finally { + AppState late; + synchronized (PUBLISH_LOCK) { + publishing = false; + late = pendingPublish; + } + if (late != null) { + // A checkpoint landed between the last read and clearing the flag, so + // nothing is publishing it. Handed back to the same entry point, which + // starts one publisher and keeps the ordering total. + publishToRelay(late); + } } } }, "Continuity relay publish").start(); @@ -738,6 +874,14 @@ static void deliver(final AppState state) { // 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; + } synchronized (lastSeen) { Long seen = lastSeen.get(state.getDeviceId()); if (seen != null && seen.longValue() >= state.getSequence()) { @@ -746,10 +890,11 @@ static void deliver(final AppState state) { lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); } if (!Display.isInitialized()) { - parked = state; + setParked(state); return; } Display.getInstance().callSerially(new Runnable() { + @Override public void run() { dispatch(state); } @@ -766,8 +911,10 @@ private static void dispatch(AppState state) { park(state); return; } - for (int i = 0; i < listeners.size(); i++) { - ContinuityListener l = listeners.get(i); + // A copy, because a listener that reacts by unregistering itself is ordinary and would + // otherwise mutate the list being walked. + List snapshot = new ArrayList(listeners); + for (ContinuityListener l : snapshot) { boolean accepted; try { accepted = l.stateReceived(state); @@ -784,17 +931,20 @@ private static void dispatch(AppState state) { if (autoRestore) { restore(state); } else { - parked = state; + setParked(state); } } private static void park(final AppState state) { - parked = state; - if (waitingForWindow) { - return; + setParked(state); + synchronized (HANDOFF_LOCK) { + if (waitingForWindow) { + return; + } + waitingForWindow = true; } - waitingForWindow = true; Display.getInstance().startThread(new Runnable() { + @Override public void run() { long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS; while (System.currentTimeMillis() < deadline) { @@ -807,15 +957,24 @@ public void run() { break; } } - waitingForWindow = false; + synchronized (HANDOFF_LOCK) { + waitingForWindow = false; + } if (Display.getInstance().getCurrent() == null) { return; } Display.getInstance().callSerially(new Runnable() { + @Override public void run() { - AppState waiting = parked; - if (waiting == state) { + // 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; + synchronized (HANDOFF_LOCK) { + waiting = parked; parked = null; + } + if (waiting != null) { dispatch(waiting); } } @@ -927,18 +1086,30 @@ static void reset() { bridgeOverridden = false; enabled = false; autoRestore = true; - dirty = false; flushScheduled = false; - waitingForWindow = false; - deviceId = null; title = null; sequence = 0; maxAge = 0; - parked = null; + synchronized (HANDOFF_LOCK) { + deviceId = null; + parked = null; + dirty = false; + waitingForWindow = false; + } + synchronized (PUBLISH_LOCK) { + pendingPublish = null; + } + } + + private static void setParked(AppState state) { + synchronized (HANDOFF_LOCK) { + parked = state; + } } /// 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 @@ -954,6 +1125,7 @@ public boolean continuationReceived(String activityType, Map use return true; } + @Override public void syncedStoreChanged() { com.codename1.continuity.sync.SyncedStore.notifyChanged(); } diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index e38d98757eb..3dfa8df882d 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -109,6 +109,7 @@ 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(); @@ -120,6 +121,7 @@ public void publish(AppState state) throws IOException { } } + @Override public AppState fetch() throws IOException { Response response = auth(Rest.get(url).jsonContent()).getAsString(); int code = response.getResponseCode(); diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index c9535e95264..38deb9046c4 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -28,7 +28,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; @@ -66,7 +65,7 @@ private StateCodec() { public static Map toMap(AppState state) { Map m = new HashMap(); m.put(KEY_ROUTES, new ArrayList(state.getRoutes())); - m.put(KEY_PAYLOAD, new HashMap(state.getPayload())); + m.put(KEY_PAYLOAD, encode(state.getPayload())); m.put(KEY_DEVICE, state.getDeviceId()); if (state.getTitle() != null) { m.put(KEY_TITLE, state.getTitle()); @@ -99,8 +98,7 @@ public static AppState fromMap(Map m) { Object routes = m.get(KEY_ROUTES); if (routes instanceof List) { List paths = new ArrayList(); - for (Iterator i = ((List) routes).iterator(); i.hasNext();) { - Object path = i.next(); + for (Object path : (List) routes) { if (path instanceof String) { paths.add((String) path); } @@ -111,11 +109,9 @@ public static AppState fromMap(Map m) { if (payload instanceof Map) { Map copy = new HashMap(); Map read = (Map) payload; - for (Iterator> i = read.entrySet().iterator(); - i.hasNext();) { - Map.Entry entry = i.next(); + for (Map.Entry entry : read.entrySet()) { if (entry.getKey() instanceof String) { - copy.put((String) entry.getKey(), entry.getValue()); + copy.put((String) entry.getKey(), decode(entry.getValue())); } } // Not validated on the way in. This map came from another device, and refusing it @@ -187,9 +183,7 @@ public static void requireRepresentable(Map payload) { if (payload == null) { return; } - for (Iterator> i = payload.entrySet().iterator(); - i.hasNext();) { - Map.Entry entry = i.next(); + for (Map.Entry entry : payload.entrySet()) { if (entry.getKey() == null) { throw new IllegalArgumentException("A continuity payload cannot have a null key."); } @@ -211,6 +205,131 @@ 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; + } + if (tag == 'i') { + return Integer.valueOf(body); + } + if (tag == 'l') { + return Long.valueOf(body); + } + if (tag == 'd') { + return Double.valueOf(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; + } + 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 @@ -219,22 +338,33 @@ private static void check(Object value, String path, int depth) { + "\" nests more than 16 levels deep, or contains a cycle. Neither a property " + "list nor JSON can represent a cycle."); } - if (value == null || value instanceof String || value instanceof Integer + if (value instanceof String || 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; - for (int i = 0; i < list.size(); i++) { - check(list.get(i), path + "[" + i + "]", depth + 1); + int index = 0; + for (Object element : list) { + check(element, path + "[" + index + "]", depth + 1); + index++; } return; } if (value instanceof Map) { Map map = (Map) value; - for (Iterator> i = map.entrySet().iterator(); - i.hasNext();) { - Map.Entry entry = i.next(); + for (Map.Entry entry : map.entrySet()) { Object key = entry.getKey(); if (!(key instanceof String)) { throw new IllegalArgumentException("The continuity payload at \"" + path diff --git a/CodenameOne/src/com/codename1/continuity/StateProvider.java b/CodenameOne/src/com/codename1/continuity/StateProvider.java index 522fe99338d..036749287a7 100644 --- a/CodenameOne/src/com/codename1/continuity/StateProvider.java +++ b/CodenameOne/src/com/codename1/continuity/StateProvider.java @@ -50,8 +50,24 @@ public interface StateProvider { /// Applies a payload this provider previously produced, on this device or another one. /// /// Called before the restored screens are shown, so a form built by the route table can read - /// what was put here during its own construction. When the app has no routes, this is the - /// whole of restoration and the provider is responsible for showing a form. + /// 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 /// diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 6e2c727c195..e0ed92ec030 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -222,16 +222,16 @@ public static void notifyChanged() { 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 = new ArrayList(listeners); - for (int i = 0; i < snapshot.size(); i++) { - // Read before the try, not inside it: the compiler inserts a checked cast for - // the generic element type, and a failed cast does not throw on the iOS - // virtual machine -- so a handler wrapped around one cannot run there. - SyncedStoreListener l = snapshot.get(i); + // 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) { diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index c1c294f9497..a6444ade287 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -29,7 +29,6 @@ import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; @@ -58,14 +57,17 @@ public class LocalContinuityBridge implements ContinuityBridge { private String publishedTitle; private Map publishedInfo; + @Override public void setCallback(ContinuityCallback c) { callback = c; } + @Override public boolean isContinuationSupported() { return true; } + @Override public void publishContinuation(String activityType, String title, Map userInfo) { publishedType = activityType; @@ -73,6 +75,7 @@ public void publishContinuation(String activityType, String title, publishedInfo = userInfo == null ? null : new HashMap(userInfo); } + @Override public void clearContinuation() { publishedType = null; publishedTitle = null; @@ -152,10 +155,12 @@ public boolean simulateArrival(String activityType, Map userInfo // Synced store // ------------------------------------------------------------------ + @Override public boolean isSyncedStoreSupported() { return true; } + @Override public void syncedStorePut(String key, String value) { Preferences.set(PREFIX + key, value); List keys = indexKeys(); @@ -165,10 +170,12 @@ public void syncedStorePut(String key, String value) { } } + @Override public String syncedStoreGet(String key) { return Preferences.get(PREFIX + key, null); } + @Override public void syncedStoreRemove(String key) { Preferences.delete(PREFIX + key); List keys = indexKeys(); @@ -177,6 +184,7 @@ public void syncedStoreRemove(String key) { } } + @Override public String[] syncedStoreKeys() { List keys = indexKeys(); return keys.toArray(new String[keys.size()]); @@ -222,11 +230,11 @@ private List indexKeys() { private void writeIndex(List keys) { StringBuilder sb = new StringBuilder(); - for (Iterator i = keys.iterator(); i.hasNext();) { + for (String key : keys) { if (sb.length() > 0) { sb.append('\n'); } - sb.append(i.next()); + sb.append(key); } Preferences.set(INDEX, sb.toString()); } diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 3302311dfac..9413fc9c2ab 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -195,8 +195,7 @@ public static boolean restoreStack(List paths) { return false; } List rebuilt = new ArrayList(); - for (int i = 0; i < paths.size(); i++) { - String path = paths.get(i); + for (String path : paths) { if (path == null || path.length() == 0) { continue; } diff --git a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java index 6a54d9cdd84..d40bd9fd256 100644 --- a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java @@ -30,6 +30,7 @@ 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; @@ -56,6 +57,13 @@ /// 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 { @@ -65,41 +73,65 @@ public AndroidContinuityBridge() { } } + @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 void syncedStorePut(String key, String value) { } + @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* @@ -140,7 +172,24 @@ public void onDestroy() { @Override public void onSaveInstanceState(Bundle b) { try { - Continuity.checkpoint(); + 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 -- diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 478e3354210..613d52103ef 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -739,10 +739,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/src/com/codename1/impl/ios/IOSContinuityBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java index 7f1bcb83a63..977af99ffcb 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java @@ -66,14 +66,17 @@ class IOSContinuityBridge implements ContinuityBridge { 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) { @@ -87,6 +90,7 @@ public void publishContinuation(String activityType, String title, } } + @Override public void clearContinuation() { if (!supported) { return; @@ -98,6 +102,7 @@ public void clearContinuation() { } } + @Override public boolean isSyncedStoreSupported() { if (!supported) { return false; @@ -110,6 +115,7 @@ public boolean isSyncedStoreSupported() { } } + @Override public void syncedStorePut(String key, String value) { if (!isSyncedStoreSupported()) { return; @@ -121,6 +127,7 @@ public void syncedStorePut(String key, String value) { } } + @Override public String syncedStoreGet(String key) { if (!isSyncedStoreSupported()) { return null; @@ -133,6 +140,7 @@ public String syncedStoreGet(String key) { } } + @Override public void syncedStoreRemove(String key) { if (!isSyncedStoreSupported()) { return; @@ -144,6 +152,7 @@ public void syncedStoreRemove(String key) { } } + @Override public String[] syncedStoreKeys() { if (!isSyncedStoreSupported()) { return new String[0]; diff --git a/Samples/samples/ContinuitySample/codenameone_settings.properties b/Samples/samples/ContinuitySample/codenameone_settings.properties index d5bb6fec2da..a90409a0937 100644 --- a/Samples/samples/ContinuitySample/codenameone_settings.properties +++ b/Samples/samples/ContinuitySample/codenameone_settings.properties @@ -1,9 +1,7 @@ #Continuity sample build hints -# Declares that this project hands the user's work between their devices. The build detects the -# reference to com.codename1.continuity on its own; the hint is what lets the Certificate Wizard -# and the signing preflight know whether an iCloud capability will be wanted. -codename1.arg.ios.continuity.enabled=true # This sample touches com.codename1.continuity.sync, so the build asks for the iCloud key-value # store entitlement -- which the App ID has to grant. Uncomment to drop it and leave SyncedStore # reporting itself unsupported; handing work to a nearby device is unaffected either way. #codename1.arg.ios.continuity.sync=false +# 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/snippets/developer-guide/state-restoration-and-continuity.properties b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties index 60908c86b1f..e950f171f3c 100644 --- 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 @@ -1,9 +1,5 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. -// tag::state-restoration-and-continuity-properties-001[] -codename1.arg.ios.continuity.enabled=true -// end::state-restoration-and-continuity-properties-001[] - // tag::state-restoration-and-continuity-properties-002[] codename1.arg.ios.continuity.sync=false // end::state-restoration-and-continuity-properties-002[] diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc index 89bdfa31932..8768a8dc71e 100644 --- a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -256,16 +256,10 @@ exactly as it would on Android. [options="header"] |=== | Hint | Default | What it does -| `ios.continuity.enabled` | `false` | Declares that this project hands work between devices. The build works this out from bytecode on its own; this exists because the Certificate Wizard and the signing preflight can't read bytecode and need to know whether an iCloud capability will be wanted. -| `ios.continuity.sync` | `true` | Set `false` to skip the iCloud key-value store entitlement. +| `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. |=== -[source,properties] ----- -include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-001,indent=0] ----- - -Everything else is automatic. Referencing `com.codename1.continuity` compiles the +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 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 6e464a0d87a..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,29 +792,20 @@ static void register(List h) { + "group, no plist keys -- leaving com.codename1.documents an inert no-op " + "at runtime.")); - h.add(new Hint("ios.continuity.enabled") - .group(HintGroup.IOS) - .type(HintType.BOOLEAN) - .def("false") - .platform("ios") - .doc("Declares that this project hands the user's work between their devices. " - + "The build detects a reference to com.codename1.continuity on its own, " - + "so this is redundant for the build itself; it exists because the " - + "Certificate Wizard and the signing preflight work without reading " - + "bytecode and need to know whether an iCloud capability will be " - + "wanted.")); - h.add(new Hint("ios.continuity.sync") .group(HintGroup.IOS) .type(HintType.BOOLEAN) .def("true") .platform("ios") - .doc("Set false to skip the iCloud key-value store entitlement that a reference " - + "to com.codename1.continuity.sync would otherwise earn. Use it when the " - + "App ID has no iCloud capability and the app can live without a synced " - + "store: SyncedStore then reports itself unsupported at runtime instead " - + "of the build failing to sign. Handing work to a nearby device is " - + "unaffected -- that half needs no entitlement.")); + .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) 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 0bb26a26b66..dbc92cd16ad 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 @@ -11721,6 +11721,53 @@ static String userActivityTypesKey(List> intents, String con return "\nNSUserActivityTypes" + types + ""; } + /// Rewrites a self-closing `NSUserActivityTypes` array into an open/close pair. + /// + /// `` is the ordinary XML spelling of an empty array and a plist parser reads it + /// exactly as ``. `mergeUserActivityTypes` looks for the literal pair, so + /// without this an application that declared the key that way took the merge branch and had + /// every id silently dropped -- the one outcome worse than a duplicate key, because nothing + /// says so until Handoff does not work on a device. + /// + /// Only this key's array is touched, and only when it is the key's immediate value: another + /// key's empty array is none of this method's business. + /// + /// @param inject the plist fragment the application supplied + /// @return the fragment, with this one array expanded when it needed it + static String expandEmptyUserActivityArray(String inject) { + if (inject == null) { + return null; + } + int key = plistKeyIndex(inject, "NSUserActivityTypes"); + if (key < 0) { + return inject; + } + int afterKey = inject.indexOf("', afterKey); + if (afterKey < 0) { + return inject; + } + int at = afterKey + 1; + // The key's IMMEDIATE value, so only whitespace may separate them. Scanning forward for + // the next "', 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); + } + static String mergeUserActivityTypes(String inject, List> intents) { return mergeUserActivityTypes(inject, intents, null); } @@ -14310,13 +14357,24 @@ public boolean accept(File file, String string) { // 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")) { + // 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. + if (plistKeyIndex(plistWithoutComments(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, continuityActivityType); + // + // 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/maven/IOSProvisioningPreflight.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java index bdf32d8f3fd..4d2cdb418dd 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 @@ -233,6 +233,9 @@ static List check(Properties settings, boolean release, Date now) { /** * 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 @@ -245,27 +248,27 @@ static List check(Properties settings, boolean release, Date now) { * the app working with {@code SyncedStore.isSupported()} reporting false, so a warning that * names the two ways out is more useful than a refusal.

* - * @return one problem when the profile demonstrably lacks the entitlement, none when it has - * it, the sync half is switched off, or nothing readable says either way + * @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.enabled")))) { - // The project has not said it wants a synced store. The builder decides this from - // bytecode, which this check cannot read -- so an app that uses the API without - // setting the hint is simply not checked here, and finds out at codesign as it does - // today. Guessing from anything else would warn projects that use no continuity at - // all. - return problems; - } - if ("false".equals(trimmed(settings.getProperty( "codename1.arg.ios.continuity.sync")))) { - // Explicitly opted out: the build declares no entitlement, so there is nothing the - // profile has to grant. return problems; } String override = trimmed(settings.getProperty("codename1.arg.ios.entitlements.com.apple" 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 index 280226de9f8..be19e0020a7 100644 --- 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 @@ -183,6 +183,91 @@ void aSpacedClosingTagIsStillMergedInto() { 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); + } + @Test void nothingToAddLeavesTheFragmentAlone() { String inject = "NSUserActivityTypes" 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 index 137f7df1522..f9f1a9250e6 100644 --- 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 @@ -98,7 +98,7 @@ private Properties settings(File appProfile) throws Exception { p.setProperty("codename1.packageName", "com.example.app"); p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true), appProfile.getAbsolutePath()); - p.setProperty("codename1.arg.ios.continuity.enabled", "true"); + p.setProperty("codename1.arg.ios.continuity.sync", "true"); return p; } @@ -133,14 +133,28 @@ public void theOptOutSkipsTheCheckEntirely() throws Exception { } /** - * A project that has not said it uses continuity is not checked. The builder decides that - * from bytecode, which this cannot read -- and guessing from anything else would warn - * projects that use none of it. + * 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 aProjectThatDeclaresNoContinuityIsNotChecked() throws Exception { + public void aProjectThatDeclaresNoSyncedStoreIsNotChecked() throws Exception { Properties p = settings(profile("NoCloud", false)); - p.remove("codename1.arg.ios.continuity.enabled"); + 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()); } @@ -163,7 +177,7 @@ public void anExplicitContainerIsLeftAlone() throws Exception { public void anUnreadableProfileIsLeftToTheOtherChecks() throws Exception { Properties p = new Properties(); p.setProperty("codename1.packageName", "com.example.app"); - p.setProperty("codename1.arg.ios.continuity.enabled", "true"); + p.setProperty("codename1.arg.ios.continuity.sync", "true"); p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true), "/nowhere/missing.mobileprovision"); 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 index b04d59aa067..30c854d6dde 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -109,6 +109,141 @@ public void externalizableRoundTripPreservesEveryField() throws Exception { 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(); 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 index e22db1f6a9e..99e8273f27b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -391,6 +391,121 @@ public void autoRestoreOffLeavesTheStateForTheApplication() { assertEquals("later", waiting.getPayload().get("note")); } + /** + * 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.awaitQuiet(); + + 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"); + } + + /** 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()); + private volatile long lastFinished; + + public void publish(AppState state) { + try { + Thread.sleep(15); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + published.add(Long.valueOf(state.getSequence())); + lastFinished = System.currentTimeMillis(); + } + + public AppState fetch() { + return null; + } + + /// Waits until the relay has been quiet for a moment, so the assertions read a settled + /// list rather than a race of their own. + void awaitQuiet() { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } + if (!published.isEmpty() && System.currentTimeMillis() - lastFinished > 300L) { + return; + } + } + } + } + // ------------------------------------------------------------------ // The synced store // ------------------------------------------------------------------ @@ -446,6 +561,18 @@ public void storeChanged() { // Helpers // ------------------------------------------------------------------ + 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); 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 3ee8a310d0d..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 @@ -140,8 +140,8 @@ The framework already knows the `@Route` navigation stack and restores it with n | Hint (`codename1.arg.` prefix) | Effect | | --- | --- | -| `ios.continuity.enabled=true` | Declares the feature. Redundant for the build, which detects the API reference itself, but it is how the Certificate Wizard and the signing preflight know an iCloud capability will be wanted. | | `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. From 24614c12afa7d6a2bc4d4427bd09122c265c864e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:47:13 +0300 Subject: [PATCH 004/140] Fix the CLDC11 break, and merge into the live activity array Two fixes, one of them a break I put in CI. StateCodec used Long.valueOf(String), Integer.valueOf(String) and Double.valueOf(String). Core is compiled a second time against Ports/CLDC11 and translated against vm/JavaAPI, and neither carries the String-taking overloads -- only valueOf(primitive) -- so the Maven build accepted them against the full JDK and the Ant leg refused them: "incompatible types: String cannot be converted to long". Now X.valueOf(X.parseX(s)), which both replacements define. The local gate that catches this is `ant -f CodenameOne/build.xml compile`, and it reproduces the CI message at the same line in twelve seconds. Not running it is what let this reach CI; the fast Maven compile check cannot see it by construction. The merge now targets the LIVE activity array. A project that kept an old declaration commented out above its real one had the ids inserted into the comment: the caller correctly saw a live key, and the merge then found the dead one first. The plist that shipped had no continuity type in the array iOS reads, so Handoff was never advertised and nothing said so. This is the case the previous commit recorded as deliberately unhandled, on the grounds that it was rare and the fix meant threading comment-aware offsets through the merge. Reviewed again and that was the wrong call: firstLiveIndex plus insideComment is twenty lines, and it also covers the expander, which had the same hole. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 10 ++-- .../com/codename1/builders/IPhoneBuilder.java | 41 ++++++++++++++- .../IPhoneBuilderContinuityPlistTest.java | 50 +++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 38deb9046c4..c12e2ca56e9 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -300,14 +300,18 @@ private static Object decode(Object value) { 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(body); + return Integer.valueOf(Integer.parseInt(body)); } if (tag == 'l') { - return Long.valueOf(body); + return Long.valueOf(Long.parseLong(body)); } if (tag == 'd') { - return Double.valueOf(body); + return Double.valueOf(Double.parseDouble(body)); } if (tag == 'b') { return Boolean.valueOf(body); 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 dbc92cd16ad..b5a8454726e 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 @@ -11721,6 +11721,35 @@ static String userActivityTypesKey(List> intents, String con 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 + static int firstLiveIndex(String plist, String key) { + int at = plistKeyIndex(plist, key); + while (at >= 0 && insideComment(plist, at)) { + at = plistKeyIndex(plist, key, at + 1); + } + return at; + } + + /// Whether `at` falls inside an `` 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; + } + /// Rewrites a self-closing `NSUserActivityTypes` array into an open/close pair. /// /// `` is the ordinary XML spelling of an empty array and a plist parser reads it @@ -11738,7 +11767,7 @@ static String expandEmptyUserActivityArray(String inject) { if (inject == null) { return null; } - int key = plistKeyIndex(inject, "NSUserActivityTypes"); + int key = firstLiveIndex(inject, "NSUserActivityTypes"); if (key < 0) { return inject; } @@ -11785,8 +11814,16 @@ static String mergeUserActivityTypes(String inject, List> in // 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"); + // 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 = firstLiveIndex(inject, "NSUserActivityTypes"); int open = key < 0 ? -1 : plistElementIndex(inject, "array", key); + while (open >= 0 && insideComment(inject, open)) { + open = plistElementIndex(inject, "array", open + 1); + } int close = open < 0 ? -1 : plistCloseElementIndex(inject, "array", open); if (close < 0) { return inject; 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 index be19e0020a7..f820724ac3b 100644 --- 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 @@ -268,6 +268,56 @@ void aLiveDeclarationBesideACommentedOneCountsAsSupplied() { 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` 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; + } + int afterKey = plist.indexOf("', afterKey); + if (afterKey < 0) { + return -1; + } + int at = afterKey + 1; + for (;;) { + while (at < plist.length() && Character.isWhitespace(plist.charAt(at))) { + at++; + } + if (plist.startsWith("", at + 4); + if (end < 0) { + return -1; + } + at = end + 3; + continue; + } + return at < plist.length() ? at : -1; + } + } + /// Rewrites a self-closing `NSUserActivityTypes` array into an open/close pair. /// /// `` is the ordinary XML spelling of an empty array and a plist parser reads it @@ -11771,22 +11812,8 @@ static String expandEmptyUserActivityArray(String inject) { if (key < 0) { return inject; } - int afterKey = inject.indexOf("', afterKey); - if (afterKey < 0) { - return inject; - } - int at = afterKey + 1; - // The key's IMMEDIATE value, so only whitespace may separate them. Scanning forward for - // the next "', at); @@ -11820,11 +11847,15 @@ static String mergeUserActivityTypes(String inject, List> in // shipped had no continuity type in the array iOS actually reads, so Handoff was never // advertised and nothing anywhere said so. int key = firstLiveIndex(inject, "NSUserActivityTypes"); - int open = key < 0 ? -1 : plistElementIndex(inject, "array", key); - while (open >= 0 && insideComment(inject, open)) { - open = plistElementIndex(inject, "array", open + 1); + // 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("" + 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" 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 index 48733db1ff2..3f3d0977f2e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -607,6 +607,127 @@ interface Condition { } } + /** + * 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(); + r.awaitQuiet(); + + 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(); + } + + void awaitQuiet() { + try { + Thread.sleep(300); + } 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 = From 1b23a36a6fa5fb57d00b06db52f03f4eb92ab9eb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:53:27 +0300 Subject: [PATCH 010/140] Continuity: stop a restore republishing itself, serialize polls, recheck age Three review findings, all three real. Reasoning kept at the line it applies to rather than in the PR thread, which nobody reads once the branch moves on. Applying an inbound stack is not the user navigating. restoreStack() reaches routeStackChanged(), which checkpoints, which republished the state we had just received under THIS device's id and a fresh sequence -- so the device that sent it could no longer recognize its own work. It arrived there as a foreign state, was restored, was published back, and the two devices bounced the same stack between them, re-navigating the user on every poll. An applyingRestore flag suppresses the checkpoint for exactly the duration of the restore, and the applied state is persisted locally so a cold start still lands where the user actually is. A second test pins the other half of the rule: navigation after a restore must still checkpoint, or a device that received a state once would go silent for the rest of the session. pollRelay() now runs one fetch at a time. A relay holds one document per user, so two overlapping GETs can return different states, and nothing downstream re-orders them: lastSeen is keyed by the ORIGINATING device, so a response that left first and returned second passes deduplication on its own key and paints the older screen over the newer one. Requests that arrive during a fetch are coalesced rather than dropped, because an application polling on reconnect is asking a real question. The body moved into pollOnce() first -- it had three early returns, and each would have leaked the in-flight flag and silently stopped polling for the life of the process. dispatch() rechecks the age. Arrival was 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 dispatched directly, past both the inbound check and the one in getRestorableState(). A state fresh on arrival that expired during that wait was restored anyway, which is precisely what maxAge exists to refuse. Probes, since a passing test proves nothing on its own: disabling the suppression fails both restore tests, and disabling the single-flight guard turns six polls into seven concurrent fetches (expected 1, was 7) -- the race was unbounded fan-out rather than the two-request inversion reported. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 137 +++++++++++++++--- .../continuity/LocalContinuityTest.java | 102 +++++++++++++ .../continuity/RouteStackRestoreTest.java | 48 ++++++ 3 files changed, 267 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 4ae34af0c85..ebc124ecff4 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -140,6 +140,10 @@ public final class Continuity { 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 HANDOFF_LOCK. + private static boolean applyingRestore; private static String title; private static long sequence; private static long maxAge; @@ -422,6 +426,13 @@ public static void routeStackChanged() { return; } synchronized (HANDOFF_LOCK) { + 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 (flushScheduled || !Display.isInitialized()) { @@ -637,12 +648,36 @@ public static boolean restore(AppState state) { // documented shape -- would leave the application on no screen at all. return false; } + boolean shown; + synchronized (HANDOFF_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 { - return Navigation.restoreStack(routes); + shown = Navigation.restoreStack(routes); } catch (Throwable t) { Log.e(t); - return false; + shown = false; + } finally { + synchronized (HANDOFF_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); } + return shown; } /// Asks the relay for anything newer than what is here, on a background thread. Returns @@ -673,38 +708,82 @@ public static void pollRelay() { // when our publish lands; ordering between devices is per-device sequences, maxAge and // the listener's own answer, none of which this would change. startPublisher(); - final long era; synchronized (PUBLISH_LOCK) { - era = accountEra; + 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() { - AppState fetched = null; try { - fetched = r.fetch(); + for (;;) { + pollOnce(r); + synchronized (PUBLISH_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; + return; + } + pollAgain = false; + } + } } 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); - return; - } - if (fetched == null) { - return; - } - synchronized (PUBLISH_LOCK) { - if (era != accountEra) { - // The user signed out while this request was in flight. Delivering now - // would restore the PREVIOUS account's work into the session that is - // signed in -- and clear() emptied lastSeen, so nothing downstream would - // recognize it as stale. Publishing has had this check; polling is the - // direction that actually puts the old account's work on screen. - return; + synchronized (PUBLISH_LOCK) { + polling = false; } } - deliver(fetched); } }, "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(StateRelay r) { + final long era; + synchronized (PUBLISH_LOCK) { + era = accountEra; + } + AppState fetched = null; + try { + fetched = r.fetch(); + } catch (Throwable t) { + Log.e(t); + return; + } + if (fetched == null) { + return; + } + synchronized (PUBLISH_LOCK) { + if (era != accountEra) { + // The user signed out while this request was in flight. Delivering now would + // restore the PREVIOUS account's work into the session that is signed in -- and + // clear() emptied lastSeen, so nothing downstream would recognize it as stale. + // Publishing has had this check; polling is the direction that actually puts the + // old account's work on screen. + return; + } + } + deliver(fetched); + } + /// Forgets everything: the stored checkpoint, any parked arrival, the activity advertised to /// the user's other devices, and anything queued for the relay. /// @@ -861,6 +940,12 @@ private static void clearContinuation() { /// session. Guarded by PUBLISH_LOCK. private static long accountEra; + /// True while a relay fetch is in flight; `pollAgain` records a poll asked for during one. + /// Both guarded by PUBLISH_LOCK. + private static boolean polling; + + private static boolean pollAgain; + private static final Object PUBLISH_LOCK = new Object(); /// Hands a state to the relay, in order, one at a time. @@ -1074,6 +1159,15 @@ private static boolean stillDeliverable(AppState state, long era) { } private static void dispatch(AppState state) { + 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 @@ -1268,9 +1362,12 @@ static void reset() { parked = null; dirty = false; waitingForWindow = false; + applyingRestore = false; } synchronized (PUBLISH_LOCK) { pendingPublish = null; + polling = false; + pollAgain = false; } } 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 index 3f3d0977f2e..bc3a8a02385 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -554,6 +554,108 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { "a different state was sent, so the failed one was not the one retained"); } + /** + * 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"); + } + + /** 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; 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 index f41db7a2e84..c8fe7337f3c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java @@ -111,6 +111,54 @@ void restoringRebuildsEveryFrameAndShowsOnlyTheLast() { assertEquals(Arrays.asList("/home", "/users", "/users/42"), dispatcher.dispatched); } + /** + * 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")); From 09d100b0b3268654522e9e1f21cd97d9e148f8a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:19:21 +0300 Subject: [PATCH 011/140] Continuity: drain the publisher across an era change, resolve the store, scope to root Three review findings, all real. The first is a bug this branch introduced one commit ago, which is worth saying plainly: the pre-send era check added there cleared `publishing` and returned. publishToRelay() queues a state, sees publishing == true, and leaves it for the live worker on the understanding that a live worker always drains the slot -- so a checkpoint made on the NEW account right after a clear() was stranded until something else happened to start a publisher. The check now continues the loop instead, and the loop's first block re-dequeues under one lock and stands down properly when there is nothing left. The previous message claimed the eras "partition cleanly"; it had verified that no state is SENT under the wrong account and not that every queued state is still sent at all. SyncedStore.addChangeListener now resolves the platform store. enable() installs the Java callback, but on iOS the external-change observer is created the first time cn1ContinuityStore() runs, and nothing on the listener path reached it: an application that only registered a listener and waited 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. NSUserActivityTypes is now resolved at the plist root only. The fragment ios.plistInject supplies is a sequence of the root dictionary's own members, but a member's value may itself be a -- and a key inside one belongs to that dictionary, not to the plist. iOS reads this key at the root and nowhere else, so a nested one was merged into a dictionary nobody reads it from AND suppressed the root key that advertises Handoff: silently inert, with an unrelated property quietly rewritten. Detection, the merge and expandEmptyUserActivityArray all use the same root-scoped lookup, because the third resolves the same key and scoping only the two the review named would still have rewritten a nested . Probes: removing the store resolution fails the listener test, and neutralizing the dict-depth check fails three plist tests in each builder. The publisher fix has no test -- it needs clear() to land between two adjacent lock holds with no code between them, so there is no hook -- and is verified by reading only. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 14 +++- .../continuity/sync/SyncedStore.java | 7 ++ .../com/codename1/builders/IPhoneBuilder.java | 78 ++++++++++++++++++- .../IPhoneBuilderContinuityPlistTest.java | 62 +++++++++++++++ .../continuity/LocalContinuityTest.java | 39 ++++++++++ 5 files changed, 195 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index ebc124ecff4..2d80d03de03 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1026,8 +1026,18 @@ public void run() { // 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. - publishing = false; - return; + // + // 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 { diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 7aec1c5f2a9..196ee0e5725 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -203,6 +203,13 @@ public static void addChangeListener(SyncedStoreListener l) { // An app that only ever uses the synced store never touches Continuity itself, and would // otherwise register a listener nothing could ever reach. Continuity.enable(); + // 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. 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 11c3303edfb..92bcbdbf954 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 @@ -11729,6 +11729,78 @@ static String userActivityTypesKey(List> intents, String con /// @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; + } + if (plist.startsWith("", open + 4); + if (commentEnd < 0) { + break; + } + i = commentEnd + 3; + 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. + static int firstLiveRootIndex(String plist, String key) { + int at = plistKeyIndex(plist, key); + while (at >= 0 && (insideComment(plist, at) || plistDictDepth(plist, at) != 0)) { + at = plistKeyIndex(plist, key, at + 1); + } + return at; + } + static int firstLiveIndex(String plist, String key) { int at = plistKeyIndex(plist, key); while (at >= 0 && insideComment(plist, at)) { @@ -11808,7 +11880,7 @@ static String expandEmptyUserActivityArray(String inject) { if (inject == null) { return null; } - int key = firstLiveIndex(inject, "NSUserActivityTypes"); + int key = firstLiveRootIndex(inject, "NSUserActivityTypes"); if (key < 0) { return inject; } @@ -11846,7 +11918,7 @@ static String mergeUserActivityTypes(String inject, List> in // 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 = firstLiveIndex(inject, "NSUserActivityTypes"); + 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 @@ -14431,7 +14503,7 @@ public boolean accept(File file, String string) { // 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. - if (plistKeyIndex(plistWithoutComments(inject), "NSUserActivityTypes") < 0) { + if (firstLiveRootIndex(plistWithoutComments(inject), "NSUserActivityTypes") < 0) { inject += userActivityTypesKey(intentsManifest, continuityActivityType); } else { // Merge into the array the application supplied rather than replacing it: its 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 index e861e15c0b2..2b009e57588 100644 --- 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 @@ -117,6 +117,68 @@ void nothingToDeclareWritesNoKey() { assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), "")); } + // ------------------------------------------------------------------ + // Only the root dictionary's own declaration counts + // ------------------------------------------------------------------ + + /** + * 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 // ------------------------------------------------------------------ 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 index bc3a8a02385..561ba87ef43 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -584,6 +584,45 @@ public void overlappingPollsNeverRunTwoFetchesAtOnce() { "the polls requested during the first fetch were dropped rather than coalesced"); } + /** + * 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(); + + @Override + public boolean isSyncedStoreSupported() { + queries.incrementAndGet(); + return super.isSyncedStoreSupported(); + } + + int storeQueries() { + return queries.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 = From cbc892122e4771ebbd92cb0081ebdb0d5ff8b5eb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:13:44 +0300 Subject: [PATCH 012/140] Continuity: one lock for all state, and five review fixes The five findings first, then the reason there kept being findings. A synced-store listener no longer enables continuity. com.codename1.continuity.sync is a package of its own so its cost is earned separately, and enable() is not a cost it asks for: it makes every route change checkpoint, and a checkpoint advertises the app's navigation to the devices around it over Handoff. An application that wanted a key/value store its user's devices share was opted into broadcasting its route stack. installSyncedStoreCallback() installs only the inbound seam; the store notification does not consult `enabled`, so the listener still works. setRelay() invalidates the work of the relay it replaces. 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 a relay the app had removed could still deliver its answer. An oversized payload string is refused up front, naming the key. Util.writeObject writes every String with writeUTF, which throws past 65535 bytes; persist() logged that and carried on, so the checkpoint looked successful and was simply absent after the process died -- state restoration failing silently at the one moment it exists for. The limit counts bytes, not characters. plistDictDepth and the key lookup go through skipMarkupBefore, the scanner this file already had. A hand-rolled comment skip read " ]]>" as real nesting, so a root NSUserActivityTypes looked nested and a SECOND one was appended -- and plistWithoutComments read a "", open + 4); - if (commentEnd < 0) { - break; - } - i = commentEnd + 3; + // 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); @@ -11793,9 +11801,30 @@ static String plistTagName(String tag) { /// 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("" 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 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 index 2b009e57588..18118ca727a 100644 --- 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 @@ -121,6 +121,62 @@ void nothingToDeclareWritesNoKey() { // 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 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 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 index d3e872d5cad..251a260ef79 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -419,4 +419,49 @@ private static AppState sample() { .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); + } + }); + } } 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 index 561ba87ef43..23bcc4c873b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -584,6 +584,72 @@ public void overlappingPollsNeverRunTwoFetchesAtOnce() { "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 @@ -612,15 +678,28 @@ 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. */ From 51d0eb757fa9609dd2965efcadfa2e8ebbfe8ffc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:49:09 +0300 Subject: [PATCH 013/140] Continuity: sweep the three defect classes rather than the reported instances Every finding on this branch has had one of two shapes, so this sweeps both surfaces by enumeration instead of patching what was reported. Cross-thread state with no memory model. IOSContinuityCallbacks had four statics and zero synchronization: the platform hands a continuation over on its own thread -- on a cold launch, before the EDT has run init() -- while setCallback runs on the EDT, so an arrival parked by one was not guaranteed visible to the other, and the take-and-clear was not atomic with the store. Either drops the continuation, which is the single thing that class exists to prevent. The same treatment as Continuity: one lock, and a re-read of the callback before parking so an arrival that races an enable() is delivered instead of stranded for a setCallback that has already been and gone. LocalContinuityBridge had the same shape and it was live -- the simulator's Simulate menu runs on the AWT thread and read fields the EDT writes, so it could report "nothing to deliver" for a state just checkpointed. Bespoke markup scanning. immediateValueIndex stepped over whitespace and comments but not processing instructions, so "NSUserActivityTypes" resolved to the "x -->" counted as declared and the type was never added -- Handoff silently not advertised. Both now go through skipMarkupBefore, which this tree already had for exactly this and whose javadoc warns against the heuristic the hand-rolled versions kept reproducing. Validating the reported instance instead of the class. Payload strings were capped; routes, title and deviceId were not, and all four reach Util.writeUTF. externalize() then threw on a long route, persist() logged it and carried on, and the checkpoint was published to the other device while silently absent from local storage. All four surfaces are checked now. The preflight no longer returns early when the project names its own key-value container. A profile granting NO store at all fails codesigning whichever container is named, so that was the one answer it could give for certain and it was suppressing it; the unanswerable question is WHICH container, and that is still where the check stops. The existing test asserted the old behaviour and is replaced by two: granting profile stays quiet, non-granting profile warns and names the container. Probes: each fix fails its own test when reverted. The two lock fixes have no executed test -- neither the iOS callback path nor the AWT/EDT interleaving is reachable from this harness -- and are verified by reading. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 18 +++++ .../continuity/LocalContinuityBridge.java | 68 +++++++++++++++---- .../impl/ios/IOSContinuityCallbacks.java | 59 +++++++++++++--- .../com/codename1/builders/IPhoneBuilder.java | 40 ++++++++--- .../maven/IOSProvisioningPreflight.java | 19 ++++-- .../IPhoneBuilderContinuityPlistTest.java | 33 +++++++++ .../maven/IOSContinuitySyncPreflightTest.java | 29 ++++++-- .../continuity/AppStateWireTest.java | 34 ++++++++++ 8 files changed, 255 insertions(+), 45 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java index c9472fb516e..d335d01a668 100644 --- a/CodenameOne/src/com/codename1/continuity/AppState.java +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -92,10 +92,19 @@ public List getRoutes() { 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; @@ -205,6 +214,11 @@ public String getDeviceId() { /// /// 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; } @@ -229,6 +243,10 @@ public String getTitle() { /// /// 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; } diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 8ae036d60b2..e60c51b5168 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -52,6 +52,17 @@ public class LocalContinuityBridge implements ContinuityBridge { /// 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(); + private ContinuityCallback callback; private String publishedType; private String publishedTitle; @@ -59,7 +70,9 @@ public class LocalContinuityBridge implements ContinuityBridge { @Override public void setCallback(ContinuityCallback c) { - callback = c; + synchronized (lock) { + callback = c; + } } @Override @@ -70,16 +83,24 @@ public boolean isContinuationSupported() { @Override public void publishContinuation(String activityType, String title, Map userInfo) { - publishedType = activityType; - publishedTitle = title; - publishedInfo = userInfo == null ? null : new HashMap(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() { - publishedType = null; - publishedTitle = null; - publishedInfo = null; + synchronized (lock) { + publishedType = null; + publishedTitle = null; + publishedInfo = null; + } } /// The activity type currently advertised, or null when nothing is. @@ -88,7 +109,9 @@ public void clearContinuation() { /// /// the type public String getPublishedType() { - return publishedType; + synchronized (lock) { + return publishedType; + } } /// The label currently advertised, or null. @@ -97,7 +120,9 @@ public String getPublishedType() { /// /// the label public String getPublishedTitle() { - return publishedTitle; + synchronized (lock) { + return publishedTitle; + } } /// The payload currently advertised, or null when nothing is. @@ -120,12 +145,19 @@ public Map getPublishedInfo() { /// /// true when there was an activity to deliver and the app claimed it public boolean simulateArrival() { - if (publishedType == null || publishedInfo == null) { - return false; + 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); } - Map copy = new HashMap(publishedInfo); copy.put("device", "simulated-device"); - return simulateArrival(publishedType, copy); + return simulateArrival(type, copy); } /// Delivers an arbitrary activity, for tests that build their own. @@ -139,7 +171,10 @@ public boolean simulateArrival() { /// /// true when the app claimed it public boolean simulateArrival(String activityType, Map userInfo) { - ContinuityCallback c = callback; + ContinuityCallback c; + synchronized (lock) { + c = callback; + } if (c == null) { return false; } @@ -196,7 +231,10 @@ public String[] syncedStoreKeys() { /// Reports a change made "on another device", which the Simulate menu uses to exercise an /// app's `SyncedStoreListener` without a second machine. public void simulateStoreChange() { - ContinuityCallback c = callback; + ContinuityCallback c; + synchronized (lock) { + c = callback; + } if (c == null) { return; } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 81fc81d8f42..1f3683924db 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -42,7 +42,22 @@ /// 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 @@ -63,11 +78,18 @@ private IOSContinuityCallbacks() { } static void setCallback(ContinuityCallback c) { - callback = c; - String type = pendingType; - String json = pendingJson; - pendingType = null; - pendingJson = null; + String type; + String json; + synchronized (LOCK) { + // Installed and drained under one hold. A continuation landing between the two halves + // was written into a slot this method had already read and was about to clear, so it + // was dropped by the very call that exists to deliver it. + callback = c; + type = pendingType; + json = pendingJson; + pendingType = null; + pendingJson = null; + } if (c != null && type != null) { // A continuation that cold-launched the app can reach this class before the // application's init() has called Continuity.enable(), which is what installs the @@ -91,7 +113,10 @@ public static boolean nativeContinuation(String activityType, String userInfoJso if (dceGuard) { return false; } - ContinuityCallback c = callback; + 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 @@ -111,13 +136,24 @@ public static boolean nativeContinuation(String activityType, String userInfoJso // 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; } - pendingType = activityType; - pendingJson = userInfoJson; - return true; + 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)); @@ -132,7 +168,10 @@ public static void nativeSyncedStoreChanged() { if (dceGuard) { return; } - ContinuityCallback c = callback; + ContinuityCallback c; + synchronized (LOCK) { + c = callback; + } if (c == null) { return; } 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 1c7828df7e9..9896e752365 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 @@ -11880,15 +11880,24 @@ static int immediateValueIndex(String plist, int keyIndex) { while (at < plist.length() && Character.isWhitespace(plist.charAt(at))) { at++; } - if (plist.startsWith("", at + 4); - if (end < 0) { - return -1; - } - at = end + 3; + 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 "" + 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); } @@ -11961,6 +11979,10 @@ static String mergeUserActivityTypes(String inject, List> in return inject; } String existing = inject.substring(open, close); + // Compared against LIVE entries below. A raw contains() answered yes for + // "", 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"); @@ -11968,12 +11990,12 @@ 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 - && !existing.contains("" + continuityType + "")) { + && !listsLiveString(existing, continuityType)) { add.append("").append(continuityType).append(""); } if (add.length() == 0) { 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 4d2cdb418dd..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 @@ -273,12 +273,6 @@ static List checkContinuitySync(Properties settings, boolean release) { } String override = trimmed(settings.getProperty("codename1.arg.ios.entitlements.com.apple" + ".developer.ubiquity-kvstore-identifier")); - if (override != null && !override.isEmpty()) { - // The project named a container of its own, which is the shape of an app sharing a - // store with a sibling. Whether the profile grants that particular one is a question - // this cannot answer from the key alone, and warning on it would be noise. - return problems; - } Profile appProfile = appProfile(settings, release); if (appProfile == null || appProfile.applicationIdentifier == null) { // No readable profile: check() reports that, and it is not something to warn about @@ -286,8 +280,19 @@ static List checkContinuitySync(Properties settings, boolean release) { 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 " @@ -298,7 +303,7 @@ static List checkContinuitySync(Properties settings, boolean release) { + "profile, or set codename1.arg.ios.continuity.sync=false -- which drops the " + "entitlement and leaves SyncedStore reporting itself unsupported at runtime. " + "Handing work to a nearby device is unaffected either way; that half needs no " - + "entitlement.", false)); + + "entitlement." + named, false)); return problems; } 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 index 18118ca727a..25aadead9cd 100644 --- 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 @@ -137,6 +137,39 @@ void strippingCommentsFirstWouldHideALiveKeyAfterCdata() { 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 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 index f9f1a9250e6..b3c927c3284 100644 --- 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 @@ -160,18 +160,39 @@ public void aContinuityOnlyProjectIsNotWarnedAboutICloud() throws Exception { } /** - * An app sharing a store with a sibling names that sibling's container. Whether the profile - * grants that particular one is not a question this can answer from the key alone. + * 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 anExplicitContainerIsLeftAlone() throws Exception { - Properties p = settings(profile("NoCloud", false)); + 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 { 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 index 251a260ef79..73cd9c44a16 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -464,4 +464,38 @@ public void execute() { } }); } + + /** + * 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); + } + }); + } } From 8170718334e4552132bf4e369f7fcd5aaf56dbcf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:16:20 +0300 Subject: [PATCH 014/140] Continuity: rebind coalesced polls, publish `enabled` last, reach the Catalyst slice Four findings. Two are windows this branch opened itself, and saying so is the point of the comments at each line. A poll coalesced behind setRelay() now uses the replacement relay. The worker kept the relay it was started with and refreshed only the era, so the second attempt fetched from the endpoint that had just been REPLACED and stamped the answer with the new era -- which made the era check, whose entire job is to stop that, wave it through and restore the old endpoint's data. Relay and era are read as a pair, on every attempt, which is the only way the two can agree. enable() publishes `enabled` last, under the same hold as the state it depends on. The lock refactor moved the Preferences load out from under the lock -- right, and nothing slow belongs there -- but left the flag being set first, which turned a benign ordering into a publishable one: a second caller saw enabled, returned, and checkpointed against an uninitialized sequence of 0, writing 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 and nothing says so. Nested map keys are length-checked. They reach the same writeUTF as top-level ones, and this is the third round of the same class: payload strings, then routes and title and deviceId, now nested keys. The validator names an oversized key without reproducing it, since the key is the thing being reported. The iCloud key-value store entitlement reaches the Catalyst slice. A Catalyst archive is signed with the plist MacNativeBuilder writes, and that plist is built from the macNative.entitlements.* namespace alone -- so the entitlement the iOS side generates reached the iOS slice and silently missed the Mac one, leaving NSUbiquitousKeyValueStore with no container in the Mac slice of the very build that switched the shared code on. It reads the value the iOS side already resolved rather than taking 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 slices to disagree. Probes: the relay rebinding, the nested key and the Catalyst entitlement each fail their test when reverted. The enable() ordering has no test -- reproducing it needs a thread to observe the flag inside a specific window, and a probabilistic test is not something this repo tolerates -- so it is verified by reading. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 39 ++++++++++++++----- .../com/codename1/continuity/StateCodec.java | 17 +++++++- .../codename1/builders/MacNativeBuilder.java | 25 ++++++++++++ .../MacNativeBuilderEntitlementsTest.java | 38 ++++++++++++++++++ .../continuity/AppStateWireTest.java | 29 ++++++++++++++ .../continuity/LocalContinuityTest.java | 35 +++++++++++++++++ 6 files changed, 173 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 7fab1473f77..0acbc513b9f 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -200,22 +200,33 @@ public static void enable() { if (enabled) { return; } - // Checked and set under one hold. Two callers -- an application on the EDT and - // setRelay() from wherever it was configured -- both saw false and both ran the rest, - // which installs a second callback and re-reads the sequence. - enabled = true; } // Registered once, and only from here, so that a build which merely links this class -- // because something else in the framework mentions it -- never installs a callback or // touches storage. Util.register(AppState.OBJECT_ID, AppState.class); - // Loaded OUTSIDE the lock: both touch Preferences, and the rule for STATE_LOCK is that - // nothing slow happens under it. - String id = loadDeviceId(); + // 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(); synchronized (STATE_LOCK) { + if (enabled) { + // Lost the race while loading. The winner's values stand, and installing a second + // callback over theirs is the duplicate the first check already existed to stop. + return; + } deviceId = id; sequence = seq; + // Published LAST, under the same hold as the state a checkpoint needs. + enabled = true; } ContinuityBridge b = bridgeInternal(); if (b != null) { @@ -817,7 +828,7 @@ public static void pollRelay() { public void run() { try { for (;;) { - pollOnce(r); + pollOnce(); synchronized (STATE_LOCK) { if (!pollAgain) { // Observed and stood down under ONE hold, for the reason the @@ -844,10 +855,20 @@ public void run() { /// 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(StateRelay r) { + 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 { diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 23bf11f373e..05969354eb8 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -367,6 +367,15 @@ private static Map castToStringKeyed(Map in) { /// 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)) { @@ -448,7 +457,13 @@ private static void check(Object value, String path, int depth) { + (key == null ? "null" : key.getClass().getName()) + ". Only string keys can be written to a property list or to JSON."); } - check(entry.getValue(), path + "." + key, depth + 1); + // 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; } 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..f4291c362ad 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,22 @@ 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. + String ubiquityKvStore = request.getArg( + "ios.entitlements.com.apple.developer.ubiquity-kvstore-identifier", null); + if (ubiquityKvStore != null && ubiquityKvStore.trim().length() > 0) { + sb.append(" com.apple.developer.ubiquity-kvstore-identifier\n ") + .append(escapeEntitlementValue(ubiquityKvStore.trim())) + .append("\n"); + } if (extra != null && extra.trim().length() > 0) { sb.append(extra); if (!extra.endsWith("\n")) { 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..9bed6acaf38 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,44 @@ /// AVCaptureSession otherwise. class MacNativeBuilderEntitlementsTest { + /** + * 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"); + // 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); + assertTrue(body.contains("$(TeamIdentifierPrefix)$(CFBundleIdentifier)"), + "the container the iOS side resolved 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/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java index 73cd9c44a16..6e4711149a9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -498,4 +498,33 @@ public void execute() { } }); } + + /** + * 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/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java index 23bcc4c873b..0a7d4b0efc3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -554,6 +554,41 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { "a different state was sent, so the failed one was not the one retained"); } + /** + * 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: From 1951b9cd23ee9f4642fb6a0022efed8dd751785d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:43:54 +0300 Subject: [PATCH 015/140] Continuity: survive a restart, honour a reconnect, capture on the EDT Three findings fixed, one pushed back on, and a test of my own that proved nothing. Delivery high-water marks survive a restart. `lastSeen` is process-local, so a relaunch 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 against a documented guarantee that an acted-on state acts once. The stored checkpoint already carries the id and sequence of whatever was last acted on, including a state restored from another device, so enable() seeds from it rather than persisting a second structure. A reconnect that lands during a failed publish is honoured. startPublisher() saw publishing == true and left the work to the live worker, which is right for ordering -- but a worker whose attempt then FAILED requeued and stood down, forgetting the request, so a single reconnect after a failed send left the retained state unsent until some later checkpoint happened. The request is recorded and consumed once: one extra attempt per external call, not the spin the stand-down exists to avoid. capture() and checkpoint() run on the EDT. Both read the EDT-owned navigation stack and call StateProvider.saveState(), which is documented to run there, and both are public and cheap enough that calling them from a network callback is ordinary -- so they read the stack while the EDT mutated it. Marshalled with a BOUNDED wait, because the desktop EDT blocks on the AWT thread while painting and an unbounded wait could deadlock the two: a missed checkpoint beats a hung app. Not fixed, deliberately: a review asked this builder to resolve the iCloud container from the raw ios.entitlementsInject fragment as well. True in the BuildDaemon twin, which is where it is fixed -- and false here, because this builder never reads that hint (zero readers; the daemon has three), so locally the fragment reaches no plist and both slices already use the same value. The reasoning is at the line, next to the VPN entitlement that documents the same asymmetry. And a correction: the restart test as first written passed with the fix reverted. deliver() dispatches through callSerially and the test body IS the EDT, so it asserted zero whether the state had been dropped or was still queued. It drains the queue now, and fails with the seeding removed. The probe is the only reason that was caught rather than shipped as evidence. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 102 +++++++++++++++++- .../codename1/builders/MacNativeBuilder.java | 9 ++ .../continuity/LocalContinuityTest.java | 85 +++++++++++++++ .../continuity/RouteStackRestoreTest.java | 54 ++++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 0acbc513b9f..49cdf81d171 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -116,6 +116,9 @@ public final class Continuity { /// 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; + private static final List listeners = new ArrayList(); /// Highest sequence seen from each device, so a state delivered twice -- which happens @@ -228,6 +231,22 @@ public static void enable() { // Published LAST, under the same hold as the state a checkpoint needs. enabled = true; } + // Seeded from what is already on disk, because `lastSeen` is process-local and a restart + // emptied it: 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, including a state restored from another device, so it is exactly the + // high-water mark to start from. Read outside the lock; it touches Storage. + AppState acted = readStored(); + if (acted != null && acted.getDeviceId() != null && acted.getDeviceId().length() > 0 + && !acted.getDeviceId().equals(id)) { + synchronized (STATE_LOCK) { + Long seen = lastSeen.get(acted.getDeviceId()); + if (seen == null || seen.longValue() < acted.getSequence()) { + lastSeen.put(acted.getDeviceId(), Long.valueOf(acted.getSequence())); + } + } + } ContinuityBridge b = bridgeInternal(); if (b != null) { try { @@ -541,6 +560,38 @@ public void run() { /// - `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 void runOnEdt(Runnable r) { + try { + Display.getInstance().callSeriallyAndWait(r, EDT_WAIT_MILLIS); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void checkpointOnEdt() { synchronized (STATE_LOCK) { if (!enabled) { return; @@ -586,6 +637,25 @@ public static boolean isCheckpointPending() { /// /// - `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) { @@ -1055,6 +1125,14 @@ private static void clearContinuation() { 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 @@ -1092,8 +1170,14 @@ private static void startPublisher() { } synchronized (STATE_LOCK) { if (relay == null || publishing || pendingPublish == null) { - // The live publisher will pick this up when it finishes its current request, - // which is what makes the ordering total. + 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; @@ -1166,8 +1250,17 @@ public void run() { synchronized (STATE_LOCK) { if (era == accountEra && pendingPublish == null) { pendingPublish = next; - publishing = false; - return; + 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; } } } @@ -1527,6 +1620,7 @@ static void reset() { pendingPublish = null; polling = false; pollAgain = false; + publishRequested = false; } } 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 f4291c362ad..d28f7408d2c 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 @@ -403,6 +403,15 @@ private void writeEntitlementsFile(BuildRequest request, File appSrcDir, // 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) { 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 index 0a7d4b0efc3..df275fa15d9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -554,6 +554,91 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { "a different state was sent, so the failed one was not the one retained"); } + /** + * 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 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 index c8fe7337f3c..785b7932e87 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java @@ -111,6 +111,60 @@ void restoringRebuildsEveryFrameAndShowsOnlyTheLast() { 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 From b4ba67eb78487ad8e852701f873eed9855d0fdc9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:31:45 +0300 Subject: [PATCH 016/140] Continuity: keep Catalyst on the iOS container, abandon a cleared checkpoint, persist marks Four findings, every one of them a defect in code this branch added in the last two rounds. The Catalyst slice was signed for a DIFFERENT iCloud container than iOS -- by the fix that was supposed to stop exactly that. $(CFBundleIdentifier) is target-relative, and DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the Catalyst bundle id ".maccatalyst" (the same derivation the provisioning-profile block already relies on), so copying the expression verbatim produced TEAM..maccatalyst against the iOS slice's TEAM.. The iOS identifier is materialized now. $(TeamIdentifierPrefix) is left alone: same team in both targets. A checkpoint that overlaps clear() is abandoned. Building the snapshot is not quick -- it calls the application's saveState() -- and the state is not in pendingPublish yet, so clear() can neither drop it nor stamp it with the old era. Persisting would recreate the storage clear() just deleted, the continuation would re-advertise the signed-out account's work, and the relay publish would go out under the next account's credentials. The era is taken before the snapshot and rechecked before the side effects. Delivery high-water marks are durable and per-device. The previous shape reconstructed at most one id from the stored checkpoint and recovered NONE once a local navigation had overwritten it, so a duplicate from any other device still arrived and still restored. They are written on every acted-on state -- from deliver() and from restore(), because an application may apply a state itself -- bounded to 64 devices, and cleared by clear() so a signed-out account's marks cannot suppress the next account's deliveries. refreshBridge() reinstalls a sync-only callback. An app that only registers a SyncedStore listener keeps continuity off deliberately, so testing `enabled` meant the simulator's capability menu -- which swaps the bridge and calls this -- left the replacement with no callback, and every later "Change the Synced Store" did nothing. Three things my own testing caught, worth recording. The materialization NPE'd when getPackageName() was null, which surfaced as a test ERROR rather than a failure. Replacing the checkpoint-derived seed with the durable map dropped the restore() path and took four tests red -- the third time on this branch that a wider mechanism silently lost a case the narrow one covered. And the durable marks leaked between tests, because they outlive reset() by design, which silently stopped three unrelated deliveries until the setup cleared them. rememberSeen() guards only the preference write. Iterating a generic map compiles to checkcasts, and the catch(Throwable) I first wrapped the whole loop in is a handler ParparVM never runs -- CHECKCAST expands to nothing there, so a failed cast crashes natively instead. check-cast-semantics flagged 7 of them; the baseline is untouched at 191. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 182 ++++++++++++++++-- .../codename1/builders/MacNativeBuilder.java | 17 +- .../MacNativeBuilderEntitlementsTest.java | 11 +- .../continuity/LocalContinuityTest.java | 81 ++++++++ .../continuity/RouteStackRestoreTest.java | 4 + 5 files changed, 279 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 49cdf81d171..48da0247781 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -110,6 +110,17 @@ public final class Continuity { /// 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 @@ -148,6 +159,10 @@ public final class Continuity { /// 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; @@ -231,19 +246,24 @@ public static void enable() { // Published LAST, under the same hold as the state a checkpoint needs. enabled = true; } - // Seeded from what is already on disk, because `lastSeen` is process-local and a restart - // emptied it: 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, including a state restored from another device, so it is exactly the - // high-water mark to start from. Read outside the lock; it touches Storage. - AppState acted = readStored(); - if (acted != null && acted.getDeviceId() != null && acted.getDeviceId().length() > 0 - && !acted.getDeviceId().equals(id)) { + // Restored from disk, because `lastSeen` is process-local: a relaunch 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 + // against a documented guarantee that an acted-on state acts once. + // + // EVERY device's mark, not one reconstructed from the stored checkpoint. That earlier + // shape recovered at most a single id and recovered none at all once a local navigation + // had overwritten the checkpoint with this device's own state -- so a duplicate from any + // other device still arrived and still restored. Read outside the lock; it touches + // Preferences. + Map restored = readSeen(); + if (!restored.isEmpty()) { synchronized (STATE_LOCK) { - Long seen = lastSeen.get(acted.getDeviceId()); - if (seen == null || seen.longValue() < acted.getSequence()) { - lastSeen.put(acted.getDeviceId(), Long.valueOf(acted.getSequence())); + 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()); + } } } } @@ -592,16 +612,29 @@ private static void runOnEdt(Runnable r) { } private static void checkpointOnEdt() { + long era; synchronized (STATE_LOCK) { if (!enabled) { return; } dirty = false; + era = accountEra; } AppState state = capture(); if (state == null) { return; } + synchronized (STATE_LOCK) { + if (era != accountEra) { + // clear() ran while this snapshot was being built, and building it is not quick -- + // it calls the application's own saveState(). The state was not in pendingPublish + // yet, so clear() could neither drop it nor stamp it with the old era: persisting + // would recreate the storage clear() had just deleted, publishContinuation would + // re-advertise the signed-out account's work to the devices around it, and the + // relay publish would go out under the NEXT account's credentials. + return; + } + } persist(state); publishContinuation(state); publishToRelay(state); @@ -839,6 +872,11 @@ public static boolean restore(AppState state) { // 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); + // And recorded as acted on. deliver() is not the only way a state gets applied: an + // application may hand one to restore() itself, from its own transport or from + // getRestorableState(). Marking only the arrival path meant a relaunch re-delivered + // the very state the user was already looking at. + noteActedOn(state); } return shown; } @@ -999,6 +1037,10 @@ public static void clear() { 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)) { @@ -1338,6 +1380,8 @@ static void deliver(final AppState state) { lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); era = deliveryEra; } + // Durable, so the mark survives the relaunch. Outside the lock: it touches Preferences. + rememberSeen(); if (!Display.isInitialized()) { if (stillDeliverable(state, era)) { setParked(state); @@ -1488,6 +1532,103 @@ private static String loadDeviceId() { } } + /// 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; + } + boolean changed; + synchronized (STATE_LOCK) { + Long seen = lastSeen.get(from); + changed = seen == null || seen.longValue() < state.getSequence(); + if (changed) { + lastSeen.put(from, Long.valueOf(state.getSequence())); + } + } + if (changed) { + 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() { + 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); @@ -1546,6 +1687,9 @@ public static ContinuityBridge bridgeForSyncedStore() { /// 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; @@ -1561,7 +1705,18 @@ public static void installSyncedStoreCallback() { /// returns. Called by a port that swaps its bridge while the app is running, which only the /// simulator does -- a device's bridge is created once and lives as long as the process. public static void refreshBridge() { - if (!enabled) { + 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(); @@ -1615,6 +1770,7 @@ static void reset() { dirty = false; waitingForWindow = false; applyingRestore = false; + storeCallbackInstalled = false; } synchronized (STATE_LOCK) { pendingPublish = null; 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 d28f7408d2c..b78c9fb009c 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 @@ -415,8 +415,23 @@ private void writeEntitlementsFile(BuildRequest request, File appSrcDir, 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) { + container = container.replace("$(CFBundleIdentifier)", iosBundleId) + .replace("$(PRODUCT_BUNDLE_IDENTIFIER)", iosBundleId); + } sb.append(" com.apple.developer.ubiquity-kvstore-identifier\n ") - .append(escapeEntitlementValue(ubiquityKvStore.trim())) + .append(escapeEntitlementValue(container)) .append("\n"); } if (extra != null && extra.trim().length() > 0) { 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 9bed6acaf38..712b23d9b68 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 @@ -54,6 +54,7 @@ void theSyncedStoreEntitlementReachesTheCatalystSlice(@TempDir Path tmp) throws 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)"); @@ -62,8 +63,14 @@ void theSyncedStoreEntitlementReachesTheCatalystSlice(@TempDir Path tmp) throws assertTrue(body.contains("com.apple.developer.ubiquity-kvstore-identifier"), "the Mac slice was signed without the key-value store entitlement: " + body); - assertTrue(body.contains("$(TeamIdentifierPrefix)$(CFBundleIdentifier)"), - "the container the iOS side resolved did not reach the Mac slice: " + 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. */ 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 index df275fa15d9..cf9c562a80f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -27,6 +27,7 @@ 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; @@ -64,6 +65,10 @@ public class LocalContinuityTest extends UITestBase { 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 @@ -554,6 +559,82 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { "a different state was sent, so the failed one was not the one retained"); } + /** + * 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)); + // 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 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 index 785b7932e87..903066703f9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java @@ -83,6 +83,10 @@ public Form dispatch(String url) { 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(); From 97b23daae94f6e88dd0782be63bf43c6d2cb1154 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:57:50 +0300 Subject: [PATCH 017/140] Continuity: carry the relay era into delivery, hold a declined activity, serialize the marks Four findings. Two are P1, and two of the four are interactions between fixes this branch made in the last two rounds. The relay era now travels INTO the admission decision. pollOnce() validated it, released the lock and then delivered -- a check-then-act, so a clear() landing in that gap admitted the previous account's response under the new deliveryEra and the freshly emptied lastSeen, and restored it into the account that had just signed in. deliver() takes the era and asks again under the same hold that records the high-water mark; callers with no relay session pass NO_ERA. A pending iOS activity is cleared only once a callback CLAIMS it. This is where two of this branch's own fixes met: addChangeListener installs a callback without enabling continuity -- a key/value store is not consent to restore a route stack -- and on a cold launch that can happen before init() calls enable(). The callback correctly declined the parked Handoff activity, and setCallback had already erased it, so the enable() moments later had nothing to deliver: initialization order alone silently lost the continuation. Cleared now only on a claim, and only if the slot still holds the same one, so a newer arrival is not discarded either. restore(AppState) marshals onto the EDT like capture() and checkpoint(). It builds and shows forms through Navigation.restoreStack() and calls StateProvider.restoreState(), both EDT work, and it is the third public entry point -- I marshalled the other two and did not carry it here. rememberSeen() serializes its write. Two inbound channels could each snapshot lastSeen and write outside the lock, so an older snapshot carrying one device landed after a newer one carrying two: memory stayed right and the second device's mark vanished from disk, so its state was acted on again after the next restart. The lock is taken before the snapshot, and always before STATE_LOCK -- every caller arrives holding nothing, so there is no cycle. Worth naming plainly: the durable marks were added last round to FIX a duplicate-delivery bug, and the persistence itself shipped a lost update. Probes: the era guard fails its test when removed. The iOS hold, the restore marshalling and the write serialization have no executed test -- the first needs the iOS port harness this repo does not have, and the other two need interleavings the EDT-bound test harness cannot produce. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 76 ++++++++++++++++--- .../impl/ios/IOSContinuityCallbacks.java | 30 ++++++-- .../continuity/LocalContinuityTest.java | 34 +++++++++ 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 48da0247781..b76aa8a32f5 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -130,6 +130,10 @@ public final class Continuity { /// How long a non-EDT caller waits for the EDT to take its capture. private static final int EDT_WAIT_MILLIS = 2000; + /// 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 @@ -817,10 +821,28 @@ public static boolean restore() { /// #### Returns /// /// true when a form was shown - public static boolean restore(AppState state) { + 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 { @@ -988,17 +1010,11 @@ private static void pollOnce() { if (fetched == null) { return; } - synchronized (STATE_LOCK) { - if (era != accountEra) { - // The user signed out while this request was in flight. Delivering now would - // restore the PREVIOUS account's work into the session that is signed in -- and - // clear() emptied lastSeen, so nothing downstream would recognize it as stale. - // Publishing has had this check; polling is the direction that actually puts the - // old account's work on screen. - return; - } - } - deliver(fetched); + // 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 @@ -1351,6 +1367,17 @@ public static String getActivityType() { /// 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; } @@ -1358,6 +1385,9 @@ static void deliver(final AppState state) { 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. @@ -1373,6 +1403,11 @@ static void deliver(final AppState state) { } 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; @@ -1532,6 +1567,9 @@ private static String loadDeviceId() { } } + /// Serializes the durable write of the high-water marks. See rememberSeen(). + private static final Object SEEN_LOCK = new Object(); + /// Records that `state` has been acted on, durably. private static void noteActedOn(AppState state) { String from = state.getDeviceId(); @@ -1592,6 +1630,20 @@ private static Map readSeen() { /// 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); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 1f3683924db..bf3be1a449d 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -81,25 +81,43 @@ static void setCallback(ContinuityCallback c) { String type; String json; synchronized (LOCK) { - // Installed and drained under one hold. A continuation landing between the two halves - // was written into a slot this method had already read and was about to clear, so it - // was dropped by the very call that exists to deliver it. + // 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; - pendingType = null; - pendingJson = null; } if (c != null && type != null) { // A continuation that cold-launched the app can reach this class before the // application's init() has called Continuity.enable(), which is what installs the // callback -- the scene delegate hands it over from willConnectToSession, which runs // first. Delivered now instead of dropped, which is what the whole feature is for. + boolean claimed = false; try { - c.continuationReceived(type, parse(json)); + 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; + } + } + } } } 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 index cf9c562a80f..a7ee1c3c20c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -559,6 +559,40 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { "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 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 From dea0b0c8a8d755c581d9ac5ecbf8084cb027d2b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:37:18 +0300 Subject: [PATCH 018/140] Continuity: serialize checkpoint side effects with clear, and fix two lying tests Three review findings, plus an intermittent failure whose real cause was a test that could not fail. A checkpoint's side effects are serialized WITH clear() rather than merely preceded by an era check. The recheck added last round was still a check-then-act: clear() completing after the comparison released STATE_LOCK left the checkpoint free to recreate the storage just deleted, re-advertise the signed-out account's work, and queue it under the next account's credentials. They cannot run under STATE_LOCK -- they write Storage and call the platform bridge -- so a COMMIT_LOCK covers the check and all three, and clear() takes it for its whole body. Lock order is COMMIT then SEEN then STATE everywhere; a scripted check reports zero acquisitions in any other order. enable() restores the delivery marks BEFORE publishing `enabled`. Restoring them after meant another thread could see enabled, poll, and have deliver() admit into a still-empty map the very state this device acted on before the restart -- and a merge landing afterwards does not recall a delivery already queued. LocalContinuityBridge.getPublishedInfo() takes the lock. The null check and the copy raced with a clear, so new HashMap(null) could throw. Its two sibling accessors were guarded and this one was missed. The intermittent failure was NOT the reset() omission I first blamed: three runs with that fix disabled all passed. It was awaitQuiet(), which treated 300ms of idle as "finished" while the publisher coalesces and the EDT is still checkpointing, so under load the assertions ran against a half-delivered list. It waits for the sequence it asserts now. Hunting that turned up a worse one: GatedRelay.awaitQuiet was a bare sleep, and the test after it asserts an ABSENCE -- a state queued before logout must not be sent -- so a sleep that ended before the worker resumed passed without exercising anything. It waits for the positive signal first, and now fails when clear() stops dropping the queue. reset() also clears `publishing`, which every sibling flag already had. That is a real inconsistency and it stays, but it fixed nothing observable and the earlier claim that it was the cause was wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 105 ++++++++++++------ .../continuity/LocalContinuityBridge.java | 7 +- .../continuity/LocalContinuityTest.java | 61 +++++++--- 3 files changed, 125 insertions(+), 48 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index b76aa8a32f5..92237deb110 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -239,6 +239,12 @@ public static void enable() { // 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) { // Lost the race while loading. The winner's values stand, and installing a second @@ -247,29 +253,14 @@ public static void enable() { } deviceId = id; sequence = seq; - // Published LAST, under the same hold as the state a checkpoint needs. - enabled = true; - } - // Restored from disk, because `lastSeen` is process-local: a relaunch 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 - // against a documented guarantee that an acted-on state acts once. - // - // EVERY device's mark, not one reconstructed from the stored checkpoint. That earlier - // shape recovered at most a single id and recovered none at all once a local navigation - // had overwritten the checkpoint with this device's own state -- so a duplicate from any - // other device still arrived and still restored. Read outside the lock; it touches - // Preferences. - Map restored = readSeen(); - if (!restored.isEmpty()) { - synchronized (STATE_LOCK) { - 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()); - } + 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) { @@ -628,20 +619,22 @@ private static void checkpointOnEdt() { if (state == null) { return; } - synchronized (STATE_LOCK) { - if (era != accountEra) { - // clear() ran while this snapshot was being built, and building it is not quick -- - // it calls the application's own saveState(). The state was not in pendingPublish - // yet, so clear() could neither drop it nor stamp it with the old era: persisting - // would recreate the storage clear() had just deleted, publishContinuation would - // re-advertise the signed-out account's work to the devices around it, and the - // relay publish would go out under the NEXT account's credentials. - 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) { + return; + } } + persist(state); + publishContinuation(state); + publishToRelay(state); } - persist(state); - publishContinuation(state); - publishToRelay(state); } /// Internal. Whether a checkpoint is owed -- something changed since the last one was @@ -1028,6 +1021,12 @@ private static void pollOnce() { /// 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; @@ -1567,6 +1566,19 @@ private static String loadDeviceId() { } } + /// 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(); @@ -1830,6 +1842,33 @@ static void reset() { 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) { diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index e60c51b5168..86c7ae4239e 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -131,7 +131,12 @@ public String getPublishedTitle() { /// /// a copy of the payload public Map getPublishedInfo() { - return publishedInfo == null ? null : new HashMap(publishedInfo); + 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 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 index a7ee1c3c20c..a1f369609d3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -502,7 +502,7 @@ public void relayPublishesArriveInCheckpointOrder() { Continuity.checkpoint(); } long newest = Continuity.getRestorableState().getSequence(); - r.awaitQuiet(); + 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. @@ -1131,7 +1131,10 @@ public void aStateStillQueuedAtLogoutIsNeverSent() { Continuity.clear(); r.release(); - r.awaitQuiet(); + // 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); @@ -1174,9 +1177,32 @@ void release() { gate.countDown(); } - void awaitQuiet() { + /// 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(300); + Thread.sleep(50); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } @@ -1187,7 +1213,6 @@ void awaitQuiet() { static class OrderRecordingRelay implements StateRelay { final List published = java.util.Collections.synchronizedList(new ArrayList()); - private volatile long lastFinished; public void publish(AppState state) { try { @@ -1196,27 +1221,35 @@ public void publish(AppState state) { Thread.currentThread().interrupt(); } published.add(Long.valueOf(state.getSequence())); - lastFinished = System.currentTimeMillis(); } public AppState fetch() { return null; } - /// Waits until the relay has been quiet for a moment, so the assertions read a settled - /// list rather than a race of their own. - void awaitQuiet() { - long deadline = System.currentTimeMillis() + 5000L; + /// 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(50); + Thread.sleep(25); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); return; } - if (!published.isEmpty() && System.currentTimeMillis() - lastFinished > 300L) { - return; - } } } } From 1478eab7ea63007c2b417460a8bf4fe84b183648 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:21:29 +0300 Subject: [PATCH 019/140] Continuity: cancel timed-out EDT work, serialize disable, persist the mark after the act Five findings, all real. runOnEdt cancels what it gave up on. The wait is bounded -- it has to be, the desktop EDT blocks on AWT while painting -- but a bounded wait that times out left the runnable QUEUED, so restore() returned false, its caller showed the initial screen, and the restore ran afterwards and replaced it. capture() returned null and still consumed a sequence when the EDT got round to it. The runnable is guarded now and the helper reports whether it actually completed, so a caller told the operation did not happen is right about that. The iCloud store initializer is a dispatch_once. `resolved` was set BEFORE `store` was assigned, so a second thread arriving in that gap got nil back from a store that was perfectly available -- and two threads passing the check together installed the external-change observer twice, which delivers every remote change to the listener twice. disable() takes COMMIT_LOCK and bumps a lifecycle generation. Without the lock a checkpoint already past its era check published the continuation and the relay state after disable() returned, leaving Handoff advertising work while isEnabled() answers false; the commit now re-checks `enabled` as well as the era. Without the generation, a disable() arriving while enable() was still loading preferences saw false, returned, and the initializing thread then switched the framework ON after that caller had been told it was off. The durable delivery mark is written when a state is ACTED ON, not when it is admitted. A process killed between the two left a high-water mark on disk for a state no listener had seen and nothing had stored, so the next launch rejected the relay's repeat as already handled and the continuation was lost for good. The in-memory mark still goes in at admission, which is what dedups inside a session. That last one correctly broke everyDevicesHighWaterMarkSurvivesARestart: the test never drained the EDT, so nothing had actually been acted on before it "restarted", and under the new rule there is rightly no durable mark. It drains first now, so it asserts about an act that happened rather than an admission. The native change was syntax-checked against the real iOS arm64 SDK under manual reference counting, and the check was proved non-vacuous with an injected error. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 100 ++++++++++++++---- Ports/iOSPort/nativeSources/IOSNative.m | 13 ++- .../continuity/LocalContinuityTest.java | 14 +++ 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 92237deb110..c4d6fd62c04 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -148,6 +148,14 @@ public final class Continuity { /// 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 @@ -218,10 +226,12 @@ private Continuity() { /// 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 @@ -246,9 +256,11 @@ public static void enable() { // queued. The duplicate this whole mechanism exists to stop, in the window that creates it. Map restored = readSeen(); synchronized (STATE_LOCK) { - if (enabled) { - // Lost the race while loading. The winner's values stand, and installing a second - // callback over theirs is the duplicate the first check already existed to stop. + 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; @@ -276,22 +288,31 @@ public static void enable() { /// arriving states are ignored. What is already in storage is left alone -- use `clear()` to /// remove it. public static void disable() { - synchronized (STATE_LOCK) { - if (!enabled) { - return; + // 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; } - 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++; - } - setParked(null); - synchronized (STATE_LOCK) { - dirty = false; + setParked(null); + clearContinuation(); } - clearContinuation(); } /// Whether the framework is on. @@ -598,12 +619,39 @@ private static boolean offEdt() { /// 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 void runOnEdt(Runnable r) { + 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. + final boolean[] flags = new boolean[2]; + Runnable guarded = new Runnable() { + @Override + public void run() { + synchronized (flags) { + if (flags[0]) { + return; + } + } + r.run(); + synchronized (flags) { + flags[1] = true; + } + } + }; try { - Display.getInstance().callSeriallyAndWait(r, EDT_WAIT_MILLIS); + Display.getInstance().callSeriallyAndWait(guarded, EDT_WAIT_MILLIS); } catch (Throwable t) { Log.e(t); } + synchronized (flags) { + if (!flags[1]) { + flags[0] = true; + return false; + } + return true; + } } private static void checkpointOnEdt() { @@ -627,7 +675,10 @@ private static void checkpointOnEdt() { // publish went out under the NEXT account's credentials. synchronized (COMMIT_LOCK) { synchronized (STATE_LOCK) { - if (era != accountEra) { + 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; } } @@ -1414,8 +1465,7 @@ static void deliver(final AppState state, final long pollEra) { lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); era = deliveryEra; } - // Durable, so the mark survives the relaunch. Outside the lock: it touches Preferences. - rememberSeen(); + if (!Display.isInitialized()) { if (stillDeliverable(state, era)) { setParked(state); @@ -1499,6 +1549,12 @@ private static void dispatch(AppState state) { } else { setParked(state); } + // Durable only NOW. Writing it when the state was admitted meant a process killed before + // this runnable ran left a high-water mark on disk for a state nothing had acted on and + // nothing had stored -- so the next launch rejected the relay's repeat as already seen and + // the continuation was lost for good. The in-memory mark still goes in at admission, + // which is what dedups within the session; only the durable copy waits for the act. + rememberSeen(); } private static void park(final AppState state) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 6fc7b575a0d..e1a695b3cf4 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20645,11 +20645,13 @@ static id cn1ContinuitySanitize(id value) { /// symptom of that is a setting that silently fails to follow the user. static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) { static NSUbiquitousKeyValueStore *store = nil; - static BOOL resolved = NO; - if (resolved) { - return store; - } - resolved = YES; + static dispatch_once_t cn1ContinuityStoreOnce; + // dispatch_once, not a resolved flag. The flag was set BEFORE the store was assigned, so a + // second thread arriving in that gap saw "resolved" and got nil back from a store that was + // perfectly available -- and two threads passing the check together installed the + // external-change observer twice, which delivers every remote change to the listener twice. + // A one-time initializer is exactly what this is, so it says so. + dispatch_once(&cn1ContinuityStoreOnce, ^{ @try { NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore]; if (s != nil && [s synchronize]) { @@ -20666,6 +20668,7 @@ static id cn1ContinuitySanitize(id value) { } @catch (NSException *e) { store = nil; } + }); return store; } 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 index a1f369609d3..96b8ddde03c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -631,6 +631,20 @@ public void everyDevicesHighWaterMarkSurvivesARestart() { 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(); From 5d02435f7dee791d807e5e3b1051ea16d47a8894 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:18:20 +0300 Subject: [PATCH 020/140] Continuity: keep a parked state until the restore succeeds, serialize the simulated index restore() clears the parked slot only after the restore actually happened. Clearing first threw away the only copy: an off-EDT caller whose marshalled restore exceeded the bounded EDT wait got false back and the state was gone -- and because dispatch had already written the sender's durable high-water mark, the relay's retry was rejected after the next launch too. A state that was never restored became permanently unrestorable, which is the exact outcome this feature exists to prevent. LocalContinuityBridge serializes the key index. Two concurrent syncedStorePut() calls 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. The read-modify-write is under one hold now, and syncedStoreKeys() reads under the same one. The lock is static deliberately -- the index lives in Preferences, not in the object, and the simulator swaps bridges, so an instance lock would serialize nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 13 ++++++- .../continuity/LocalContinuityBridge.java | 37 ++++++++++++++----- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index c4d6fd62c04..5da810b0c85 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -849,8 +849,17 @@ public static boolean restore() { if (state == null) { return false; } - setParked(null); - return restore(state); + // 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) { + setParked(null); + } + return shown; } /// Restores a specific state: hands its payload to the provider, then replays its route stack. diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 86c7ae4239e..5c805f0b6f0 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -63,6 +63,13 @@ public class LocalContinuityBridge implements ContinuityBridge { /// 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; @@ -203,10 +210,16 @@ public boolean isSyncedStoreSupported() { @Override public boolean syncedStorePut(String key, String value) { Preferences.set(PREFIX + key, value); - List keys = indexKeys(); - if (!keys.contains(key)) { - keys.add(key); - writeIndex(keys); + synchronized (INDEX_LOCK) { + // 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? @@ -221,16 +234,22 @@ public String syncedStoreGet(String key) { @Override public void syncedStoreRemove(String key) { Preferences.delete(PREFIX + key); - List keys = indexKeys(); - if (keys.remove(key)) { - writeIndex(keys); + synchronized (INDEX_LOCK) { + List keys = indexKeys(); + if (keys.remove(key)) { + writeIndex(keys); + } } } @Override public String[] syncedStoreKeys() { - List keys = indexKeys(); - return keys.toArray(new String[keys.size()]); + 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 From 3d4d3b54ea1d03af96ac29c220630c8ff13db88d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:38:31 +0300 Subject: [PATCH 021/140] Continuity: serialize dispatch with clear, stop marking parked states handled Five findings. Two are data loss, and two of the five are places where a fix from the previous round only moved the problem along. dispatch() runs under COMMIT_LOCK. stillDeliverable() checked the era and released STATE_LOCK, so a clear() landing after that let this run listeners, restore navigation and persist the PREVIOUS account's state after the user had signed out -- an era check cannot help once it is behind us. This deliberately holds a lock across application callbacks, which STATE_LOCK never does: the only other holders are clear(), which is short and rare, and the checkpoint commit, which runs on the same EDT thread and so is reentrant. The reasoning is at the line. A parked state is no longer marked durably handled. `parked` is a field, so a process killed before the application calls restore() loses the state -- while the mark said it had been handled, and the relay's repeat was rejected on the next launch. Moving the write after dispatch last round fixed the admission case and left this one; only the branch that actually consumes the state writes now, and the parked branch gets its mark from restore() when the application accepts it. An expired parked arrival no longer hides a valid stored checkpoint. getRestorableState() cleared it and returned null, so restore() reported nothing to restore while storage held a perfectly good checkpoint -- ordinary with automatic restore off and the user still navigating -- and the application showed its initial screen instead. pollRelay() fetches before it publishes what is owed. A relay holds one document per user, so a POST reaching the endpoint first erases the other device's state, and the GET then returns this device's own echo, which deliver() drops -- the remote update never observed at all. Note this is not a reversal of the earlier refusal to serialize the FETCH behind the publish: waiting for our own POST reads back our own write. Fetch, then publish, is the only order that both sends what is owed and reads what is there. The Catalyst entitlement materializes "${CFBundleIdentifier}" as well as the parenthesised form. Xcode accepts both, so a project using the brace spelling had it left unresolved here and expanded against the DERIVED mac bundle id while iOS expanded it against its own -- two slices, two stores. It goes through replaceBuildSetting, which already knew both spellings; that helper is now package-visible rather than duplicated. Probes: reverting the parked-mark deferral and the expired-parked fallthrough each fails its own test. The dispatch serialization and the relay ordering have no executed test -- both need an interleaving this harness cannot produce. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 47 ++++++++-- .../com/codename1/builders/IPhoneBuilder.java | 6 +- .../codename1/builders/MacNativeBuilder.java | 11 ++- .../MacNativeBuilderEntitlementsTest.java | 24 +++++ .../continuity/LocalContinuityTest.java | 93 +++++++++++++++++++ 5 files changed, 169 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 5da810b0c85..896527e25e2 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -804,10 +804,14 @@ public static AppState getRestorableState() { // 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. setParked(null); - return null; + } else { + return waiting; } - return waiting; } AppState stored = readStored(); if (stored == null || isTooOld(stored)) { @@ -989,7 +993,11 @@ public static void pollRelay() { // 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. - startPublisher(); + // 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 @@ -1018,6 +1026,8 @@ public void run() { // publisher documents: releasing the lock between the two would // let a poll requested in the gap set a flag nobody ever reads. polling = false; + // Owed work goes out AFTER the fetch, never before it. + startPublisher(); return; } pollAgain = false; @@ -1514,6 +1524,22 @@ private static boolean stillDeliverable(AppState state, long era) { } private static void dispatch(AppState state) { + // 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) { + dispatchLocked(state); + } + } + + private static void dispatchLocked(AppState state) { 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 @@ -1555,15 +1581,18 @@ private static void dispatch(AppState state) { } 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 { setParked(state); } - // Durable only NOW. Writing it when the state was admitted meant a process killed before - // this runnable ran left a high-water mark on disk for a state nothing had acted on and - // nothing had stored -- so the next launch rejected the relay's repeat as already seen and - // the continuation was lost for good. The in-memory mark still goes in at admission, - // which is what dedups within the session; only the durable copy waits for the act. - rememberSeen(); } private static void park(final AppState state) { 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 9896e752365..21c55f57fc9 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 @@ -11019,7 +11019,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); } 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 b78c9fb009c..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 @@ -427,8 +427,15 @@ private void writeEntitlementsFile(BuildRequest request, File appSrcDir, String container = ubiquityKvStore.trim(); String iosBundleId = request.getPackageName(); if (iosBundleId != null && iosBundleId.length() > 0) { - container = container.replace("$(CFBundleIdentifier)", iosBundleId) - .replace("$(PRODUCT_BUNDLE_IDENTIFIER)", iosBundleId); + // 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)) 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 712b23d9b68..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,30 @@ /// 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 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 index 96b8ddde03c..b7454780e1f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -593,6 +593,99 @@ public void run() { "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"); + } + /** * 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 From 4b64b638aa34eb0bbc9c46315c332f4ee9dd033b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:01:54 +0300 Subject: [PATCH 022/140] Continuity: revalidate the era under the lock, wait out started EDT work, acknowledge() Three findings, and fixing the third exposed that a fix from the previous round had been writing nothing at all. dispatch() re-asks the delivery era AFTER taking COMMIT_LOCK. Last round it took the lock and kept using the answer stillDeliverable() gave before the lock was held -- so a clear() that got there first completed, and this dispatched the previous account's state anyway. A lock around a stale answer is not serialization. runOnEdt waits out work the EDT has already STARTED. The cancel guard only stopped a runnable that had not begun; one that started just before the deadline and ran long left the caller with false, the application showed its initial screen, and the restore landed on top of it a moment later. A started operation cannot be cancelled, so waiting is the only truthful answer -- bounded separately, because "is the EDT free" and "is this operation finished" are different questions. Continuity.acknowledge(AppState) records that the application handled a state itself. The listener contract documents doing the work and returning false, and that path never reaches restore(), so nothing was recorded durably: after a relaunch the relay's unchanged document was accepted and the listener's side effects ran again. It is NOT inferred from the false return, because false also means "keep it, I will prompt" -- marking that handled would lose the state if the process died before the user answered, which is the same data loss as marking a parked state. And the part worth being blunt about: noteActedOn() only wrote to disk when the IN-MEMORY map changed. That condition was written when the durable copy tracked memory exactly; once the mark started going into memory at admission and reaching disk only when the state was acted on, it was permanently false by the time anything called it. So it persisted nothing -- which means the parked-state fix reported as working last round was dead, and acknowledge() would have been too. It writes unconditionally now. The new test caught it only because it asserts the outcome -- the state does not come back after a restart -- rather than the mechanism. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 93 +++++++++++++++++-- .../continuity/ContinuityListener.java | 8 ++ .../continuity/LocalContinuityTest.java | 59 ++++++++++++ 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 896527e25e2..8bc7295d49a 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -130,6 +130,12 @@ public final class Continuity { /// 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; @@ -625,7 +631,8 @@ private static boolean runOnEdt(final Runnable r) { // 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. - final boolean[] flags = new boolean[2]; + // [0] cancelled, [1] completed, [2] started. + final boolean[] flags = new boolean[3]; Runnable guarded = new Runnable() { @Override public void run() { @@ -633,6 +640,7 @@ public void run() { if (flags[0]) { return; } + flags[2] = true; } r.run(); synchronized (flags) { @@ -646,11 +654,37 @@ public void run() { Log.e(t); } synchronized (flags) { - if (!flags[1]) { + 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; } - return true; + } + // 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]; } } @@ -866,6 +900,29 @@ public static boolean restore() { 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 @@ -1500,7 +1557,7 @@ public void run() { // 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); + dispatch(state, era); } } }); @@ -1524,6 +1581,11 @@ private static boolean stillDeliverable(AppState state, long era) { } private static void dispatch(AppState state) { + dispatch(state, NO_ERA); + } + + /// As above, for a delivery queued in a known run of the framework. + 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 @@ -1535,6 +1597,16 @@ private static void dispatch(AppState state) { // 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); } } @@ -1683,17 +1755,18 @@ private static void noteActedOn(AppState state) { // Our own work needs no mark: deliver() drops an echo on the device id alone. return; } - boolean changed; synchronized (STATE_LOCK) { Long seen = lastSeen.get(from); - changed = seen == null || seen.longValue() < state.getSequence(); - if (changed) { + if (seen == null || seen.longValue() < state.getSequence()) { lastSeen.put(from, Long.valueOf(state.getSequence())); } } - if (changed) { - rememberSeen(); - } + // 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. diff --git a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java index 10a3b44a96e..cf308c08447 100644 --- a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java +++ b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java @@ -41,6 +41,14 @@ public interface ContinuityListener { /// 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 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 index b7454780e1f..c0dfdd5fec8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -686,6 +686,65 @@ public void run() { + "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 From 9795de29f37ed3817083bfb62f7833c02fff6957 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:22:33 +0300 Subject: [PATCH 023/140] Continuity: carry the era through the cold-launch park, acknowledge payload-only restores Three findings, all of them in code this branch added in the last two rounds. The parked state carries its delivery era. The cold-launch waiter unparks minutes later and dispatched through an era-less overload, which skipped the revalidation inside COMMIT_LOCK entirely -- so a clear() during that wait let the previous account's listeners, navigation and persistence run after logout, on the one path where the window is longest. The era is stored beside `parked` and passed through, and the convenience overload is gone: it existed only to pass NO_ERA, which is exactly how the check came to be skipped. SpotBugs refuses an uncalled private method anyway, and that is the second orphan this branch has created by rerouting callers. restore() acknowledges whenever the state was APPLIED, not when a form appeared. A route-less continuation is applied by handing its payload to the provider -- the documented shape for an application that does not use @Route -- and Navigation.restoreStack() then returns false, so tying the acknowledgement to the return value left it unmarked: the relay offered the same state again after every restart, and with automatic restore off the no-argument wrapper reapplied it on every call. Calling restore() IS the acceptance; what it returns only says whether the caller still has to show a screen. This is the same mistake as the previous round's, one layer along -- a durable acknowledgement hung on a boolean that answers a different question. LocalContinuityBridge holds INDEX_LOCK across the value mutation too. Serializing only the index left put() and remove() able to interleave for one key: the delete could land between the value write and the index update, listing a key with no value, or put() could report success while a 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 59 +++++++++++++------ .../continuity/LocalContinuityBridge.java | 17 ++++-- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 8bc7295d49a..2cdba8d0359 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -219,6 +219,9 @@ public final class Continuity { /// 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() { } @@ -1008,12 +1011,16 @@ private static boolean restoreOnEdt(AppState state) { // 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); - // And recorded as acted on. deliver() is not the only way a state gets applied: an - // application may hand one to restore() itself, from its own transport or from - // getRestorableState(). Marking only the arrival path meant a relaunch re-delivered - // the very state the user was already looking at. - noteActedOn(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; } @@ -1580,11 +1587,13 @@ private static boolean stillDeliverable(AppState state, long era) { } } - private static void dispatch(AppState state) { - dispatch(state, NO_ERA); - } - - /// As above, for a delivery queued in a known run of the framework. + /// 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 @@ -1607,11 +1616,11 @@ private static void dispatch(AppState state, long era) { return; } } - dispatchLocked(state); + dispatchLocked(state, era); } } - private static void dispatchLocked(AppState state) { + 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 @@ -1627,7 +1636,7 @@ private static void dispatchLocked(AppState state) { // 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); + park(state, era); return; } // A copy, because a listener that reacts by unregistering itself is ordinary and would @@ -1663,12 +1672,14 @@ private static void dispatchLocked(AppState state) { // in-memory mark still goes in at admission, which is what dedups within a session. rememberSeen(); } else { - setParked(state); + // 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) { - setParked(state); + private static void park(final AppState state, long era) { + setParked(state, era); synchronized (STATE_LOCK) { if (waitingForWindow) { return; @@ -1702,12 +1713,15 @@ public void run() { // 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); + dispatch(waiting, waitingEra); } } }); @@ -2039,8 +2053,19 @@ static void reset() { } 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; } } diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 5c805f0b6f0..c184c3480fc 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -209,8 +209,14 @@ public boolean isSyncedStoreSupported() { @Override public boolean syncedStorePut(String key, String value) { - Preferences.set(PREFIX + key, 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 @@ -220,10 +226,11 @@ public boolean syncedStorePut(String key, String value) { 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)); } - // Read back rather than assume, so the simulation answers the same question the device - // does: is the value there now? - return value.equals(Preferences.get(PREFIX + key, null)); } @Override @@ -233,8 +240,8 @@ public String syncedStoreGet(String key) { @Override public void syncedStoreRemove(String key) { - Preferences.delete(PREFIX + key); synchronized (INDEX_LOCK) { + Preferences.delete(PREFIX + key); List keys = indexKeys(); if (keys.remove(key)) { writeIndex(keys); From f59c3f1764843bfe197a2d07179bce72258686a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:45:19 +0300 Subject: [PATCH 024/140] Continuity: defer a checkpoint's publish during a poll, guard the listener registry Three findings, plus a violation of this class's own locking rule that the audit script caught rather than a reviewer. A checkpoint no longer publishes while a GET is outstanding. The relay holds one document per user, so a POST landing before the answer overwrites the other device's state and the GET then reads back our own write -- the remote update never seen. The earlier ordering fix deferred only the retained work pollRelay() starts; a checkpoint arriving mid-poll still published straight over it. It sets publishRequested and the poll starts a publisher when it finishes. restore() compares before it clears the parked slot. 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 -- the blind clear threw B away, and the in-memory high-water mark stopped the relay offering it again for the session. Compared by (device, sequence), which is how this class identifies a state everywhere else: two objects carrying that pair are the same state, and a reference test would have missed one that had been through the codec. The PMD gate forbids == on objects, and it was right to. The listener registry is guarded. It is a plain ArrayList mutated from whatever thread the application registers on -- the API carries no EDT-only contract -- and snapshotted on the EDT, so a new listener could be missed, a removed one still called, or the copy taken mid-mutation. And the rule violation: the poll stand-down called startPublisher(), which spawns a thread, while holding STATE_LOCK. The rule that nothing slow or re-entrant runs under that lock is documented at its declaration, and I broke it two rounds after writing it. The scripted audit reports zero call-outs again; a rule only holds if it is re-run. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 82 ++++++++++++++++--- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 2cdba8d0359..8abeff8d9c0 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -399,8 +399,14 @@ public static StateProvider getStateProvider() { /// /// - `l`: the listener public static void addContinuationListener(ContinuityListener l) { - if (l != null && !listeners.contains(l)) { - listeners.add(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); + } } } @@ -410,7 +416,9 @@ public static void addContinuationListener(ContinuityListener l) { /// /// - `l`: the listener public static void removeContinuationListener(ContinuityListener l) { - listeners.remove(l); + synchronized (STATE_LOCK) { + listeners.remove(l); + } } /// Installs the endpoint that carries state to devices the platform will not reach, and asks @@ -898,7 +906,23 @@ public static boolean restore() { // prevent. boolean shown = restore(state); if (shown) { - setParked(null); + 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; } @@ -1083,6 +1107,7 @@ public static void pollRelay() { public void run() { try { for (;;) { + boolean standDown = false; pollOnce(); synchronized (STATE_LOCK) { if (!pollAgain) { @@ -1090,11 +1115,17 @@ public void run() { // publisher documents: releasing the lock between the two would // let a poll requested in the gap set a flag nobody ever reads. polling = false; - // Owed work goes out AFTER the fetch, never before it. - startPublisher(); - return; + standDown = true; + } else { + pollAgain = false; } - 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) { @@ -1360,7 +1391,17 @@ private static void startPublisher() { return; } synchronized (STATE_LOCK) { - if (relay == null || publishing || pendingPublish == null) { + 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 @@ -1641,7 +1682,10 @@ private static void dispatchLocked(AppState state, long era) { } // A copy, because a listener that reacts by unregistering itself is ordinary and would // otherwise mutate the list being walked. - List snapshot = new ArrayList(listeners); + List snapshot; + synchronized (STATE_LOCK) { + snapshot = new ArrayList(listeners); + } for (ContinuityListener l : snapshot) { boolean accepted; try { @@ -1762,6 +1806,20 @@ private static String loadDeviceId() { /// 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(); @@ -1994,7 +2052,9 @@ static ContinuityBridge bridgeInternal() { /// Test seam: returns the framework to its untouched state. static void reset() { - listeners.clear(); + synchronized (STATE_LOCK) { + listeners.clear(); + } synchronized (STATE_LOCK) { lastSeen.clear(); deliveryEra++; From 31d60be176fe8208463f9dad01b990f0c469e4c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:03:34 +0300 Subject: [PATCH 025/140] SyncedStore: guard the change-listener registry `listeners` is a plain ArrayList. Registration happens on whatever thread the application chooses -- the API carries no EDT-only contract -- while the external change notification checks isEmpty() and copies the list on the EDT, so a newly registered listener could be missed, a removed one still called, or the snapshot taken mid-mutation. Every read and write takes LISTENER_LOCK now, including the emptiness check, and the isInitialized() call moved out from under it so nothing but the list access happens inside. This is the sibling of the registry fixed in Continuity one round ago. Fixing one and not looking for the other is the same enumeration miss that has produced most of the defects on this branch: the shape was known, and the second instance still had to be reported. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/sync/SyncedStore.java | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 4c66923619b..48a149ee04e 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -67,6 +67,10 @@ 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() { } @@ -196,8 +200,15 @@ public static String[] keys() { /// /// - `l`: the listener public static void addChangeListener(SyncedStoreListener l) { - if (l != null && !listeners.contains(l)) { - listeners.add(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 @@ -220,13 +231,20 @@ public static void addChangeListener(SyncedStoreListener l) { /// /// - `l`: the listener public static void removeChangeListener(SyncedStoreListener l) { - listeners.remove(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() { - if (listeners.isEmpty() || !Display.isInitialized()) { + synchronized (LISTENER_LOCK) { + if (listeners.isEmpty()) { + return; + } + } + if (!Display.isInitialized()) { return; } Display.getInstance().callSerially(new Runnable() { @@ -234,8 +252,10 @@ public static void notifyChanged() { public void run() { // Copied before iterating: a listener that reacts to a change by unregistering // itself is ordinary, and would otherwise mutate the list being walked. - List snapshot = - new ArrayList(listeners); + 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. @@ -262,6 +282,8 @@ private static ContinuityBridge bridge() { /// Test seam: forgets every registered listener. static void reset() { - listeners.clear(); + synchronized (LISTENER_LOCK) { + listeners.clear(); + } } } From b12212fef3779d255ccb1f9962d63877e2a23665 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:27:26 +0300 Subject: [PATCH 026/140] Continuity: retry a failed store resolution, and clear only the state inspected Two findings, both of them defects in fixes this branch made earlier. The iCloud store resolution latches SUCCESS only. dispatch_once was introduced two rounds ago to fix a real race -- a "resolved" flag set before the store was assigned handed nil back from a perfectly good store, and two threads passing together installed the external-change observer twice. It fixed that and created something worse: [s synchronize] is the probe for "is this store usable" and it answers NO for transient reasons, an offline launch being the obvious one, so a one-time initializer cached that NO for the life of the process. An entitled app that started without connectivity reported the synced store unsupported forever, installed no observer, and never recovered when the network came back. A mutex keeps the serialization and retries after a failure; resolving again costs one synchronize, getting it permanently wrong costs the feature. getRestorableState() compares before it clears the expired parked state. It can run on a worker, and a delivery can replace the slot with a NEWER state between the snapshot and the clear -- the unconditional clear then destroyed a valid continuation whose in-memory high-water mark stopped the relay ever offering it again in this process. Same compare-and-clear the restore path already had, which is where this should have been fixed at the same time. So this time the enumeration is finished rather than the instance: all six places that clear `parked` are accounted for. disable(), clearLocked() and reset() clear everything deliberately; the cold-launch waiter TAKES the state, carrying its era with it; restore() and getRestorableState() are the two that had to compare, and both now do. The native change was syntax-checked against the real iOS arm64 SDK under manual reference counting. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 13 ++++++++- Ports/iOSPort/nativeSources/IOSNative.m | 27 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 8abeff8d9c0..c46ec8db2ad 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -853,7 +853,18 @@ public static AppState getRestorableState() { // 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. - setParked(null); + // + // 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; } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index e1a695b3cf4..869dfaa15ca 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20645,13 +20645,23 @@ static id cn1ContinuitySanitize(id value) { /// symptom of that is a setting that silently fails to follow the user. static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) { static NSUbiquitousKeyValueStore *store = nil; - static dispatch_once_t cn1ContinuityStoreOnce; - // dispatch_once, not a resolved flag. The flag was set BEFORE the store was assigned, so a - // second thread arriving in that gap saw "resolved" and got nil back from a store that was - // perfectly available -- and two threads passing the check together installed the - // external-change observer twice, which delivers every remote change to the listener twice. - // A one-time initializer is exactly what this is, so it says so. - dispatch_once(&cn1ContinuityStoreOnce, ^{ + 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]) { @@ -20668,7 +20678,8 @@ static id cn1ContinuitySanitize(id value) { } @catch (NSException *e) { store = nil; } - }); + } + pthread_mutex_unlock(&cn1ContinuityStoreLock); return store; } From d56d9eebc953aed919c1bea43122157111f85749 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:51:26 +0300 Subject: [PATCH 027/140] Continuity: validate the generation across a restore, a poll and a provider read Three findings, all in code this branch added over the last few rounds. The no-argument restore() validates the generation it started in. It takes framework-held state, so a clear() or disable() completing between the retrieval and the application put the previous account's payload, navigation stack, storage and durable mark on screen after logout. The queued inbound path is serialized against clear(); this one was not. A captured generation rather than COMMIT_LOCK, and the choice matters: restore() marshals to the EDT when called off it, so holding COMMIT_LOCK across that wait would deadlock against a checkpoint already running on the EDT and wanting the same lock. The review offered both options; only one of them is safe here. restore(AppState) deliberately does NOT validate -- an application handing us a state of its own is making an explicit decision, and second-guessing it against a logout it may itself have performed is not that method's business. A poll carries the DELIVERY generation as well as the account era. accountEra moves on clear() and setRelay() but not on disable(), so an application that disabled and re-enabled while a fetch was in flight had the answer admitted into the new run -- the account era still matched, and the admission then stamped it with the current deliveryEra, which is exactly the rejection the lifecycle generation exists to perform. restoreOnEdt reads the provider under STATE_LOCK, as captureOnEdt already did. setStateProvider is public and writes under that lock from whatever thread the application uses, so the unguarded read could hand a payload to a provider the app had just replaced, or observe the replacement without safe publication. The new test is probe-verified, but only on the second attempt: the first probe reported everything passing and nearly convinced me the test was vacuous. It was the probe that was vacuous -- a str.replace with no count assertion, and a quiet core build whose failure would have left the tests running against the previously installed jar. With the count asserted and the build visible, removing the delivery-generation check fails the test as it should. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 74 ++++++++++++++++--- .../continuity/LocalContinuityTest.java | 39 ++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index c46ec8db2ad..b3d8372a2d7 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -905,6 +905,21 @@ private static boolean isTooOld(AppState state) { /// /// true when a form was shown, so the caller should not show its own public static boolean restore() { + final long account; + final long lifecycle; + synchronized (STATE_LOCK) { + // Captured BEFORE the state is retrieved and re-checked before anything is applied. + // This path takes framework-held state, so a clear() or disable() completing in + // between would otherwise apply the previous account's payload, navigation and + // durable mark after logout -- the queued inbound path is serialized against clear() + // and this one was not. + // + // A generation rather than COMMIT_LOCK, deliberately: restore() marshals to the EDT + // when called off it, and holding COMMIT_LOCK across that wait would deadlock against + // a checkpoint already running on the EDT and wanting the same lock. + account = accountEra; + lifecycle = lifecycleEra; + } AppState state = getRestorableState(); if (state == null) { return false; @@ -915,7 +930,7 @@ public static boolean restore() { // 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); + boolean shown = restoreValidated(state, account, lifecycle); if (shown) { synchronized (STATE_LOCK) { // Compare-and-clear, not a blind clear. An off-EDT caller takes state A and waits @@ -974,6 +989,15 @@ public static void acknowledge(AppState state) { /// /// true when a form was shown public static boolean restore(final AppState state) { + // No generation: an application handing us a state of its own is making an explicit + // decision, and second-guessing it against a logout it may itself have just performed is + // not this method's business. The no-argument wrapper, which takes framework-held state, + // is the one that validates. + return restoreValidated(state, NO_ERA, NO_ERA); + } + + private static boolean restoreValidated(final AppState state, final long account, + final long lifecycle) { if (state == null) { return false; } @@ -986,16 +1010,31 @@ public static boolean restore(final AppState state) { runOnEdt(new Runnable() { @Override public void run() { - out[0] = restoreOnEdt(state); + out[0] = restoreOnEdt(state, account, lifecycle); } }); return out[0]; } - return restoreOnEdt(state); + return restoreOnEdt(state, account, lifecycle); } - private static boolean restoreOnEdt(AppState state) { - StateProvider p = provider; + private static boolean restoreOnEdt(AppState state, long account, long lifecycle) { + synchronized (STATE_LOCK) { + if ((account != NO_ERA && account != accountEra) + || (lifecycle != NO_ERA && lifecycle != lifecycleEra)) { + // A clear() or a disable() landed between the retrieval and here. Applying now + // would put the previous account's work on screen and write its durable mark. + return false; + } + } + StateProvider p; + synchronized (STATE_LOCK) { + // Read under the lock, as captureOnEdt() already does. setStateProvider() is public + // and writes under STATE_LOCK from whatever thread the application uses, so an + // unguarded read here could hand a payload to a provider the app had just replaced, + // or observe the replacement without safe publication. + p = provider; + } if (p != null) { try { // Before the routes, so a form the route table is about to build can read what @@ -1156,6 +1195,7 @@ public void run() { /// never the polling loop -- which is why the stand-down lives in the caller. private static void pollOnce() { final long era; + final long delivery; final StateRelay r; synchronized (STATE_LOCK) { // Relay and era read as a PAIR, on every attempt. The worker used to keep the relay @@ -1164,6 +1204,12 @@ private static void pollOnce() { // 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; + // The DELIVERY generation as well. accountEra moves on clear() and setRelay() but NOT + // on disable(), so an application that disabled and re-enabled while a fetch was in + // flight had the answer admitted into the new run: the account era still matched, and + // the admission then stamped it with the CURRENT deliveryEra -- which is precisely the + // rejection the lifecycle generation exists to perform. + delivery = deliveryEra; r = relay; if (r == null) { return; @@ -1183,7 +1229,7 @@ private static void pollOnce() { // 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); + deliver(fetched, era, delivery); } /// Forgets everything: the stored checkpoint, any parked arrival, the activity advertised to @@ -1563,6 +1609,11 @@ static void deliver(final AppState state) { /// 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) { + deliver(state, pollEra, NO_ERA); + } + + /// As above, also rejecting a state fetched in an earlier run of the framework. + static void deliver(final AppState state, final long pollEra, final long pollDelivery) { if (state == null) { return; } @@ -1570,7 +1621,8 @@ static void deliver(final AppState state, final long pollEra) { if (!enabled) { return; } - if (pollEra != NO_ERA && pollEra != accountEra) { + if ((pollEra != NO_ERA && pollEra != accountEra) + || (pollDelivery != NO_ERA && pollDelivery != deliveryEra)) { return; } } @@ -1588,9 +1640,11 @@ static void deliver(final AppState state, final long pollEra) { } 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) { + // Re-asked under the SAME hold that records the mark, so a logout -- or a disable and + // re-enable -- between the check above and this one cannot slip an old state past + // both. + if ((pollEra != NO_ERA && pollEra != accountEra) + || (pollDelivery != NO_ERA && pollDelivery != deliveryEra)) { return; } Long seen = lastSeen.get(state.getDeviceId()); 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 index c0dfdd5fec8..6ef467bce7e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -745,6 +745,45 @@ public void run() { + "effects would run a second time"); } + /** + * A relay answer fetched before a disable() must not be admitted after a re-enable. + * accountEra moves on clear() and setRelay() but NOT on disable(), so carrying only that + * generation let work started in the previous run restore into the new one -- precisely the + * rejection the lifecycle generation exists to perform. + */ + @EdtTest + public void aStateFetchedBeforeADisableIsNotAdmittedAfterReEnable() { + Continuity.enable(); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + // What a poll captured before the application switched continuity off and on again. + AppState inFlight = foreign("device-preexisting", 7); + Continuity.disable(); + Continuity.enable(); + + // era 0 was this session's account era when the fetch started; the delivery generation + // has moved twice since. + Continuity.deliver(inFlight, 0L, 0L); + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + try { + Thread.sleep(250); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + + assertEquals(0, seen[0], + "a state fetched before the disable was restored into the re-enabled run"); + } + /** * 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 From f7611722dbb0de7fe03faabecf7a57ae39e956a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:52:29 +0300 Subject: [PATCH 028/140] Continuity: bind every arrival to the generation, and report an EDT failure to its caller Two findings, both holes in machinery this branch added over the last two rounds. A platform continuation carries no era of its own -- the operating system has no notion of ours -- so it arrives through the NO_ERA overload and BOTH era predicates were skipped for exactly that caller. A clear() or disable() landing between deliver()'s two locked blocks then let the arrival through and stamped it with the NEW deliveryEra: the previous account's state reached the listeners and the screen after logout, or restored after the framework had been switched off. The gap is real time rather than theoretical, because getDeviceId() and isTooOld() sit between those blocks and each takes and releases the lock. The question is reframed. It was "does your era match", which a caller with no era could not answer; it is now "did anything change while I was deciding", observed at the first check and required unchanged at admission. Every caller can answer that one, and the era arguments still add the stricter check for callers that have one. runOnEdt reports what the EDT threw. capture() refuses an unrepresentable payload by throwing, deliberately and with the key named, and marshalled to the EDT that throw died inside the guarded runnable -- so an off-EDT caller waited out the full timeout and got null back. A programming error the exception exists to surface became a silent nothing, and only when called off the EDT, which is the worst shape for a diagnostic. It is captured, always completed in a finally so a throwing operation is not mistaken for one that never started, and rethrown on the waiting thread with its type preserved. The test for that second one was vacuous first time, and its own comment said why: an @EdtTest body never reaches the marshalled path, so it asserted only the on-EDT behaviour that was never broken and passed with the exception still swallowed. It uses invokeAndBlock now -- which runs on a separate thread AND frees the EDT to service what capture() marshals to it -- and removing the capture fails it. The generation test asserts the positive case too: a platform arrival whose generation did not move must still be delivered, or a guard that refused everything would pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 64 +++++++++++-- .../continuity/LocalContinuityTest.java | 90 +++++++++++++++++++ 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index b3d8372a2d7..67255e2f857 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -644,6 +644,7 @@ private static boolean runOnEdt(final Runnable r) { // 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]; + final Throwable[] failure = new Throwable[1]; Runnable guarded = new Runnable() { @Override public void run() { @@ -653,9 +654,24 @@ public void run() { } flags[2] = true; } - r.run(); - synchronized (flags) { - flags[1] = true; + try { + r.run(); + } catch (Throwable t) { + // Kept for the waiting thread. capture() refuses an unrepresentable payload + // by throwing, deliberately and with the key named -- and marshalled to the + // EDT that throw died here, so an off-EDT caller waited out the full timeout + // and got null. The programming error the exception exists to surface became + // a silent nothing, and only when called off the EDT. + synchronized (flags) { + failure[0] = t; + } + } finally { + // ALWAYS. Marking completion only on the success path made a throwing + // operation look like one that never started, so the caller then waited out + // the whole started-work cap for something already finished. + synchronized (flags) { + flags[1] = true; + } } } }; @@ -666,6 +682,7 @@ public void run() { } synchronized (flags) { if (flags[1]) { + rethrow(failure[0]); return true; } if (!flags[2]) { @@ -695,10 +712,29 @@ public void run() { } } synchronized (flags) { + rethrow(failure[0]); return flags[1]; } } + /// Re-throws on the waiting thread what the EDT threw, preserving its type. + /// + /// An unchecked failure has to reach the caller as itself: StateCodec refuses an + /// unrepresentable payload with an IllegalArgumentException naming the key, and an + /// application debugging that must see the same exception whichever thread it called from. + private static void rethrow(Throwable t) { + if (t == null) { + return; + } + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } + if (t instanceof Error) { + throw (Error) t; + } + throw new RuntimeException(t); + } + private static void checkpointOnEdt() { long era; synchronized (STATE_LOCK) { @@ -1617,6 +1653,8 @@ static void deliver(final AppState state, final long pollEra, final long pollDel if (state == null) { return; } + final long observedAccount; + final long observedDelivery; synchronized (STATE_LOCK) { if (!enabled) { return; @@ -1625,6 +1663,15 @@ static void deliver(final AppState state, final long pollEra, final long pollDel || (pollDelivery != NO_ERA && pollDelivery != deliveryEra)) { return; } + // OBSERVED here, required unchanged below. A caller that supplies no generation -- a + // platform continuation arrives that way, since the OS has no notion of our eras -- + // skipped both predicates entirely, so a clear() or disable() landing between these + // two locked blocks let the arrival through and stamped it with the NEW deliveryEra. + // The previous account's state then reached the listeners and the screen after + // logout. What every caller can be held to, era or not, is that nothing changed while + // this was deciding. + observedAccount = accountEra; + observedDelivery = deliveryEra; } if (getDeviceId().equals(state.getDeviceId())) { // This device's own echo, which a relay returns as a matter of course. @@ -1640,11 +1687,12 @@ static void deliver(final AppState state, final long pollEra, final long pollDel } final long era; synchronized (STATE_LOCK) { - // Re-asked under the SAME hold that records the mark, so a logout -- or a disable and - // re-enable -- between the check above and this one cannot slip an old state past - // both. - if ((pollEra != NO_ERA && pollEra != accountEra) - || (pollDelivery != NO_ERA && pollDelivery != deliveryEra)) { + // Re-asked under the SAME hold that records the mark, so a logout -- or a disable + // and re-enable -- between the check above and this one cannot slip an old state past + // both. Against the OBSERVED generation, so this holds for a platform arrival that + // carried no era of its own; getDeviceId() and isTooOld() above each take the lock + // and release it, so the gap is real time. + if (observedAccount != accountEra || observedDelivery != deliveryEra) { return; } Long seen = lastSeen.get(state.getDeviceId()); 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 index 6ef467bce7e..edf37c3bb37 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -784,6 +784,96 @@ public void run() { "a state fetched before the disable was restored into the re-enabled run"); } + /** + * A platform continuation carries no generation of its own -- the OS has no notion of our + * eras -- so both era predicates are skipped for it. What it can still be held to is that + * nothing changed while delivery was being decided, and without that a clear() landing + * between the two locked checks admitted the arrival and stamped it with the NEW generation. + */ + @EdtTest + public void aPlatformArrivalIsStillRejectedWhenTheGenerationMoves() { + Continuity.enable(); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + // A state whose maxAge check will run while we move the generation underneath it: the + // isTooOld() and getDeviceId() calls in deliver() both take and release the lock. + AppState arrival = foreign("device-platform", 11); + Continuity.disable(); + Continuity.enable(); + + // NO_ERA on both, which is exactly how a platform continuation is delivered. + Continuity.deliver(arrival); + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + try { + Thread.sleep(250); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + + // It IS admitted here -- nothing moved during the decision -- which is the correct + // behaviour and what makes the guard a guard rather than a blanket refusal. + assertEquals(1, seen[0], "a platform arrival with a settled generation must be delivered"); + } + + /** + * An off-EDT capture() must report an unrepresentable payload the same way the on-EDT path + * does. Marshalled to the EDT, the IllegalArgumentException died in the runnable: the caller + * waited out the timeout and got null, so the programming error the exception exists to name + * became a silent nothing -- and only when called off the EDT. + */ + @EdtTest + public void anInvalidPayloadFailsTheSameWayFromAnyThread() { + Continuity.enable(); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map bad = new HashMap(); + bad.put("unsupported", new StringBuilder("not a representable type")); + return bad; + } + + public void restoreState(Map state) { + } + }); + + // On the EDT, where this test body runs: the refusal is immediate. This half was never + // broken, and asserting only it is what made the first version of this test vacuous -- + // it passed with the exception swallowed, because it never reached the marshalled path. + boolean threwOnEdt = false; + try { + Continuity.capture(); + } catch (IllegalArgumentException expected) { + threwOnEdt = true; + } + assertTrue(threwOnEdt, "capture() must refuse an unrepresentable payload"); + + // And OFF the EDT, which is the path that swallowed it. invokeAndBlock runs this on a + // separate thread and releases the EDT to process what capture() marshals to it, so + // offEdt() is true here and the call really does go through runOnEdt. + final Throwable[] caught = new Throwable[1]; + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + try { + Continuity.capture(); + } catch (Throwable t) { + caught[0] = t; + } + } + }); + + assertTrue(caught[0] instanceof IllegalArgumentException, + "an off-EDT capture() reported " + caught[0] + " instead of the " + + "IllegalArgumentException the on-EDT path raises for the same payload"); + } + /** * An app that only registers a store listener keeps continuity OFF by design -- a key/value * store is not consent to broadcast a route stack. refreshBridge() tested `enabled` alone, so From b60dca3b86d88c023e095e7a37d1c4a4aef1f7aa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:01:08 +0300 Subject: [PATCH 029/140] IOSContinuityCallbacks: hold a continuation the installed callback declined Two fixes from earlier rounds, each right on its own, combined into a regression. The pending-hold added earlier protects the case where NO callback is installed yet. But SyncedStore.addChangeListener() installs one WITHOUT enabling continuity -- deliberately, because a key/value store is not consent to broadcast a route stack -- so an application that registers a store listener before calling enable() has a live callback that answers false to everything. A continuation arriving in that window was handed to it, refused, and dropped, and the enable() moments later had nothing to recover. Registering an unrelated store listener silently turned a parked cold-launch continuation into a lost one. A declined arrival is held now, on the SAME rule the no-callback path uses: declined only on a positive type mismatch. That asymmetry matters on a cold launch, where expectedTypeOrNull() can legitimately answer null before the stub has published the package name -- treating "cannot tell" as "not ours" would discard the framework's own launch activity, which is the one case this machinery exists for. The rule is copied rather than reinvented: two branches of this class answering the same question differently is how it went wrong before. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/ios/IOSContinuityCallbacks.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index bf3be1a449d..7943d21b296 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -173,12 +173,37 @@ public static boolean nativeContinuation(String activityType, String userInfoJso } } } + boolean claimed = false; try { - return c.continuationReceived(activityType, parse(userInfoJson)); + claimed = c.continuationReceived(activityType, parse(userInfoJson)); } catch (Throwable t) { Log.e(t); return false; } + if (claimed) { + return true; + } + // DECLINED, which is not the same as "not ours". A callback is installed by + // SyncedStore.addChangeListener() as well as by Continuity.enable(), and the store + // listener deliberately leaves continuity disabled -- so an app that registers one before + // enabling has a live callback that answers false to everything. A continuation arriving + // in that window used to be handed over, refused, and dropped, and the enable() moments + // later had nothing to recover: registering an unrelated store listener turned a parked + // cold-launch continuation into a lost one. + // + // Held on the same rule the no-callback path uses: 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 discard the framework's + // own cold launch -- the one case this exists for. + String stillExpected = expectedTypeOrNull(); + if (stillExpected != null && !stillExpected.equals(activityType)) { + return false; + } + synchronized (LOCK) { + pendingType = activityType; + pendingJson = userInfoJson; + } + return true; } /// The synced store changed on another of the user's devices. From 244558575fa6353b7dd7de7b024e00eeb559ed90 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:55:26 +0300 Subject: [PATCH 030/140] Continuity: drop the locking and run on the EDT like the rest of the toolkit Codename One is single threaded. This feature had grown three locks, three generation counters, a bounded EDT round trip with two timeouts, and a script to audit the lock ordering -- none of which it needs, because there was never a second thread touching the state they guarded. The cause was a design decision here, not the review findings that followed it: StateRelay was given an off-EDT contract and framework state was then touched from those threads. Every round found another interleaving and each was fixed where it was found, so the machinery compounded. It is all removed: - Every mutable static is EDT-owned and plain. No locks, no volatile, no eras. - A port delivering a continuation marshals with callSerially AT the boundary, and answers the platform's synchronous yes/no from the activity type alone, which needs no framework state. - Relay publish/fetch still run on a worker -- they block, and blocking the EDT is worse -- but the worker is handed the state as a parameter, touches no field, and returns its answer through callSerially. - One int survives: a relay round trip outlives the EDT turn that started it, so a fetch begun before a logout can return after one. The session is compared on the EDT. Bookkeeping, not a memory model. Two guarantees are deliberately dropped with their tests, because both existed only to justify a generation counter and neither describes something an app does: an arrival that predates a disable()/enable() cycle now dispatches, and a relay fetch is no longer rejected for belonging to an earlier enable-run. disable() still ignores everything from the moment it is called. Two of the guards removed were protecting against nothing at all. LocalContinuityBridge's lock was documented as covering simulator menu calls from the AWT thread; SimulatorHookLoader dispatches every hook through callSeriallyAndWait, so it was on the EDT throughout. The one genuine off-EDT read did exist -- Android's onSaveInstanceState asked isCheckpointPending() from Android's main thread -- and that check now happens on the far side of the hop, as does the resume poll. Also in this change: The delegate matches the continuation type EXACTLY, against a type the build resolved and wrote to the plist as CN1ContinuityActivityType. A suffix test claimed any activity whose type merely ended in ".continuity". Deriving the exact type natively from the bundle identifier looks equivalent and is wrong on the Mac slice: DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes that id ".maccatalyst", so Handoff would have been silently dead on the Mac-to-iPhone case the feature exists for. The exp004 telemetry integration test retries a POST while -- and only while -- the response is Cloudflare's pre-worker 404, which is what failed CI on this branch. The suite gates on one healthy readiness round and its own comments note that propagation is neither monotonic nor global, so a later POST can still hit an edge that has not caught up. Retrying is safe in exactly this case and no other: a route 404 is produced before the worker runs, so nothing was counted. Reproduced both ways by injecting the fault. Continuity suite 89/89, plugin 53/53, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, worker suite green locally. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 1583 +++++------------ .../continuity/sync/SyncedStore.java | 63 +- .../continuity/LocalContinuityBridge.java | 130 +- .../continuity/AndroidContinuityBridge.java | 44 +- .../nativeSources/CodenameOne_GLAppDelegate.m | 28 +- .../impl/ios/IOSContinuityCallbacks.java | 214 +-- .../exp004-telemetry/test/integration.mjs | 90 +- .../com/codename1/builders/IPhoneBuilder.java | 58 + .../IPhoneBuilderContinuityPlistTest.java | 71 + .../continuity/LocalContinuityTest.java | 228 ++- 10 files changed, 955 insertions(+), 1554 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 67255e2f857..3fb955a08a5 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -98,6 +98,20 @@ /// Referencing this package is what makes the build declare the activity type on Apple platforms /// and compile the native continuation handling in. An app that never touches /// `com.codename1.continuity` gets none of it. +/// +/// #### Threading +/// +/// Call this class from the event dispatch thread, like the rest of the toolkit. Codename One is +/// single threaded: every method here runs on the EDT and every field it keeps is owned by the +/// EDT, so there is nothing to synchronize and nothing that can interleave. +/// +/// Two kinds of foreign thread exist, and both hand over at the boundary rather than reaching in. +/// A port delivering a continuation arrives on the platform's own thread and is marshalled with +/// `com.codename1.ui.Display#callSerially`. The relay's `publish` and `fetch` are blocking calls +/// that must not sit on the EDT, so they run on a worker -- one that is handed the state it needs +/// as a parameter, touches no field of this class, and returns its answer through `callSerially` +/// as well. That is the whole concurrency design, and it is deliberately the toolkit's: one +/// thread on each side of a boundary, never two on the same state. public final class Continuity { /// The `Storage` entry the checkpoint is written to. static final String STORAGE_KEY = "CN1$Continuity"; @@ -127,45 +141,13 @@ public final class Continuity { /// 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. + // EDT-owned, like every field in this class. See the threading note on the class. private static StateProvider provider; private static StateRelay relay; private static ContinuityBridge bridge; @@ -175,52 +157,37 @@ public final class Continuity { 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. + /// for the user moving and republished. private static boolean applyingRestore; /// True once a synced-store listener has asked for the inbound seam, independently of - /// `enabled`. Guarded by STATE_LOCK. + /// `enabled`. 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. + /// The device id, lazily created. 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. + /// Whether a checkpoint is owed. 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. + /// True while the cold-launch waiter is running, so a second arrival does not start another. private static boolean waitingForWindow; - /// A state that arrived and could not be shown yet. Guarded by STATE_LOCK. + /// A state that arrived and could not be shown yet. private static AppState parked; - /// The delivery era `parked` arrived in, or NO_ERA. Guarded by STATE_LOCK. - private static long parkedEra; + /// Which relay session the in-flight worker belongs to, bumped by `clear()`, `setRelay()` and + /// `reset()`. + /// + /// Ordinary bookkeeping rather than a memory-model device: a relay round trip is the one + /// thing here that outlives the EDT turn that started it, so a fetch begun before a logout + /// can return after one. The worker carries the session it was started in and the completion + /// -- which runs back on the EDT -- ignores an answer whose session has moved on, rather than + /// delivering the previous account's state into the next account's screen. + private static int relaySession; private Continuity() { } @@ -235,54 +202,27 @@ private Continuity() { /// 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; + if (enabled) { + return; } // Registered once, and only from here, so that a build which merely links this class -- // because something else in the framework mentions it -- never installs a callback or // touches storage. Util.register(AppState.OBJECT_ID, AppState.class); - // 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. + // getDeviceId() rather than loadDeviceId(): it is the one that mints and persists a UUID, + // so the id this device sends is the id it will still be using after a restart. + deviceId = getDeviceId(); + sequence = loadSequence(); + // Merged rather than replaced. A mark already in memory is from this run and is at least + // as new as the stored one. Map restored = readSeen(); - 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; + 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()); } - 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; } + enabled = true; ContinuityBridge b = bridgeInternal(); if (b != null) { try { @@ -297,31 +237,13 @@ public static void enable() { /// 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(); + if (!enabled) { + return; } + enabled = false; + dirty = false; + parked = null; + clearContinuation(); } /// Whether the framework is on. @@ -330,9 +252,7 @@ public static void disable() { /// /// true when enabled public static boolean isEnabled() { - synchronized (STATE_LOCK) { - return enabled; - } + return enabled; } /// Whether this platform can save and restore state at all. False only where there is no @@ -376,9 +296,7 @@ public static boolean isContinuationSupported() { /// /// - `p`: the provider, or null to contribute nothing beyond the route stack public static void setStateProvider(StateProvider p) { - synchronized (STATE_LOCK) { - provider = p; - } + provider = p; enable(); } @@ -388,9 +306,7 @@ public static void setStateProvider(StateProvider p) { /// /// the provider public static StateProvider getStateProvider() { - synchronized (STATE_LOCK) { - return provider; - } + return provider; } /// Registers a listener for states arriving from elsewhere. @@ -399,14 +315,8 @@ public static StateProvider getStateProvider() { /// /// - `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); - } + if (l != null && !listeners.contains(l)) { + listeners.add(l); } } @@ -416,9 +326,7 @@ public static void addContinuationListener(ContinuityListener l) { /// /// - `l`: the listener public static void removeContinuationListener(ContinuityListener l) { - synchronized (STATE_LOCK) { - listeners.remove(l); - } + listeners.remove(l); } /// Installs the endpoint that carries state to devices the platform will not reach, and asks @@ -428,19 +336,13 @@ public static void removeContinuationListener(ContinuityListener l) { /// /// - `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; - } + // A different endpoint is a different destination for anything queued for the old one and + // a different source for a fetch already in flight. Without this a state retained after a + // failed send was published to the REPLACEMENT endpoint -- an application's data sent + // somewhere it was never handed to -- and a poll started against the relay the app has + // just removed could still deliver its answer afterwards. + endRelaySession(); + relay = r; if (r != null) { enable(); pollRelay(); @@ -453,9 +355,7 @@ public static void setRelay(StateRelay r) { /// /// the relay public static StateRelay getRelay() { - synchronized (STATE_LOCK) { - return relay; - } + return relay; } /// Whether a restorable state found at startup, or arriving from another device, is applied @@ -469,9 +369,7 @@ public static StateRelay getRelay() { /// /// - `b`: true to restore automatically public static void setAutoRestore(boolean b) { - synchronized (STATE_LOCK) { - autoRestore = b; - } + autoRestore = b; } /// Whether automatic restoration is on. @@ -480,9 +378,7 @@ public static void setAutoRestore(boolean b) { /// /// true when on public static boolean isAutoRestore() { - synchronized (STATE_LOCK) { - return autoRestore; - } + return autoRestore; } /// Sets the label a receiving device may show before the user accepts a continuation -- "Draft @@ -493,9 +389,7 @@ public static boolean isAutoRestore() { /// /// - `t`: the label, or null for none public static void setTitle(String t) { - synchronized (STATE_LOCK) { - title = t; - } + title = t; } /// The current continuation label, or null. @@ -504,9 +398,7 @@ public static void setTitle(String t) { /// /// the label public static String getTitle() { - synchronized (STATE_LOCK) { - return title; - } + return title; } /// How old a stored state may be and still be restored, in milliseconds. Zero, the default, @@ -520,9 +412,7 @@ public static String getTitle() { /// /// - `millis`: the limit, or 0 for none public static void setMaxAge(long millis) { - synchronized (STATE_LOCK) { - maxAge = millis < 0 ? 0 : millis; - } + maxAge = millis < 0 ? 0 : millis; } /// The staleness limit in milliseconds, or 0 for none. @@ -531,9 +421,7 @@ public static void setMaxAge(long millis) { /// /// the limit public static long getMaxAge() { - synchronized (STATE_LOCK) { - return maxAge; - } + return maxAge; } /// This installation's device id, the value that lets a state be recognized as this device's @@ -543,18 +431,15 @@ public static long getMaxAge() { /// /// 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; + String id = deviceId; + if (id != null) { + return id; } + // Read rather than cached here. enable() is what installs the field, and this is + // reachable before that -- a continuation can arrive on a cold launch -- so the + // pre-enable path answers from storage instead. loadDeviceId() persists the UUID it + // mints, so asking twice gives the same answer; there is no second identity to create. + return loadDeviceId(); } // ------------------------------------------------------------------ @@ -565,36 +450,26 @@ public static String getDeviceId() { /// 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 (!enabled) { + return; } - if (!Display.isInitialized()) { + 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; } - 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; + dirty = true; + if (!Display.isInitialized() || flushScheduled) { + return; } + // Coalesced: a burst of navigations in one cycle costs a single write. + flushScheduled = true; Display.getInstance().callSerially(new Runnable() { @Override public void run() { - synchronized (STATE_LOCK) { - flushScheduled = false; - } + flushScheduled = false; if (isCheckpointPending()) { checkpoint(); } @@ -613,160 +488,17 @@ public void run() { /// - `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(); - } - }); + if (!enabled) { 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]; - final Throwable[] failure = new Throwable[1]; - Runnable guarded = new Runnable() { - @Override - public void run() { - synchronized (flags) { - if (flags[0]) { - return; - } - flags[2] = true; - } - try { - r.run(); - } catch (Throwable t) { - // Kept for the waiting thread. capture() refuses an unrepresentable payload - // by throwing, deliberately and with the key named -- and marshalled to the - // EDT that throw died here, so an off-EDT caller waited out the full timeout - // and got null. The programming error the exception exists to surface became - // a silent nothing, and only when called off the EDT. - synchronized (flags) { - failure[0] = t; - } - } finally { - // ALWAYS. Marking completion only on the success path made a throwing - // operation look like one that never started, so the caller then waited out - // the whole started-work cap for something already finished. - synchronized (flags) { - flags[1] = true; - } - } - } - }; - try { - Display.getInstance().callSeriallyAndWait(guarded, EDT_WAIT_MILLIS); - } catch (Throwable t) { - Log.e(t); - } - synchronized (flags) { - if (flags[1]) { - rethrow(failure[0]); - 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) { - rethrow(failure[0]); - return flags[1]; - } - } - - /// Re-throws on the waiting thread what the EDT threw, preserving its type. - /// - /// An unchecked failure has to reach the caller as itself: StateCodec refuses an - /// unrepresentable payload with an IllegalArgumentException naming the key, and an - /// application debugging that must see the same exception whichever thread it called from. - private static void rethrow(Throwable t) { - if (t == null) { - return; - } - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } - if (t instanceof Error) { - throw (Error) t; - } - throw new RuntimeException(t); - } - - private static void checkpointOnEdt() { - long era; - synchronized (STATE_LOCK) { - if (!enabled) { - return; - } - dirty = false; - era = accountEra; - } + dirty = false; 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); - } + persist(state); + publishContinuation(state); + publishToRelay(state); } /// Internal. Whether a checkpoint is owed -- something changed since the last one was @@ -779,9 +511,7 @@ private static void checkpointOnEdt() { /// /// true when `checkpoint()` would write something new public static boolean isCheckpointPending() { - synchronized (STATE_LOCK) { - return enabled && dirty; - } + return enabled && dirty; } /// Builds a state from the route stack and the provider. Useful for sending one somewhere of @@ -799,32 +529,10 @@ public static boolean isCheckpointPending() { /// /// - `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; + if (!enabled) { + return null; } + StateProvider p = provider; AppState state = new AppState(); state.setRoutes(currentRoutes()); if (p != null) { @@ -844,13 +552,9 @@ private static AppState captureOnEdt() { state.setPayload(payload); } } - long seq; - String label; - synchronized (STATE_LOCK) { - sequence = nextSequence(); - seq = sequence; - label = title; - } + sequence = nextSequence(); + long seq = sequence; + String label = title; // Persisted HERE rather than in persist(), which only checkpoint() reaches. capture() is // public and documented for sending a state through the application's own transport, and // a counter that only advanced durably on the checkpoint path restarted lower after a @@ -875,10 +579,7 @@ private static AppState captureOnEdt() { /// /// 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; - } + AppState 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 @@ -889,18 +590,7 @@ public static AppState getRestorableState() { // 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; - } - } + parked = null; } else { return waiting; } @@ -917,12 +607,8 @@ public static AppState getRestorableState() { /// 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; + return maxAge > 0 && state.getTimestamp() > 0 + && System.currentTimeMillis() - state.getTimestamp() > maxAge; } /// Restores whatever `getRestorableState()` offers. @@ -941,50 +627,17 @@ private static boolean isTooOld(AppState state) { /// /// true when a form was shown, so the caller should not show its own public static boolean restore() { - final long account; - final long lifecycle; - synchronized (STATE_LOCK) { - // Captured BEFORE the state is retrieved and re-checked before anything is applied. - // This path takes framework-held state, so a clear() or disable() completing in - // between would otherwise apply the previous account's payload, navigation and - // durable mark after logout -- the queued inbound path is serialized against clear() - // and this one was not. - // - // A generation rather than COMMIT_LOCK, deliberately: restore() marshals to the EDT - // when called off it, and holding COMMIT_LOCK across that wait would deadlock against - // a checkpoint already running on the EDT and wanting the same lock. - account = accountEra; - lifecycle = lifecycleEra; - } 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 = restoreValidated(state, account, lifecycle); - 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; - } - } + // only copy, and because dispatch had already written the sender's durable mark, a relay + // retry was rejected after the next launch too -- a state that was never restored then + // could not be restored at all, which is the one outcome this feature exists to prevent. + boolean shown = restore(state); + if (shown && isSameState(parked, state)) { + parked = null; } return shown; } @@ -1025,52 +678,17 @@ public static void acknowledge(AppState state) { /// /// true when a form was shown public static boolean restore(final AppState state) { - // No generation: an application handing us a state of its own is making an explicit - // decision, and second-guessing it against a logout it may itself have just performed is - // not this method's business. The no-argument wrapper, which takes framework-held state, - // is the one that validates. - return restoreValidated(state, NO_ERA, NO_ERA); - } - - private static boolean restoreValidated(final AppState state, final long account, - final long lifecycle) { 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, account, lifecycle); - } - }); - return out[0]; - } - return restoreOnEdt(state, account, lifecycle); - } - - private static boolean restoreOnEdt(AppState state, long account, long lifecycle) { - synchronized (STATE_LOCK) { - if ((account != NO_ERA && account != accountEra) - || (lifecycle != NO_ERA && lifecycle != lifecycleEra)) { - // A clear() or a disable() landed between the retrieval and here. Applying now - // would put the previous account's work on screen and write its durable mark. - return false; - } - } - StateProvider p; - synchronized (STATE_LOCK) { - // Read under the lock, as captureOnEdt() already does. setStateProvider() is public - // and writes under STATE_LOCK from whatever thread the application uses, so an - // unguarded read here could hand a payload to a provider the app had just replaced, - // or observe the replacement without safe publication. - p = provider; - } + // NOT serialized against clear(), and nothing here needs to be. A review asked twice for + // a lock around the provider and navigation work below, on the reading that a worker can + // call clear() midway through and have this recreate the checkpoint it just deleted. + // There is no such worker: clear() is event-thread API like every other method on this + // class, so it runs either entirely before this or entirely after it. The lock that + // question asks for is the one that made this class need a lock-ordering rule to be + // safe from itself. See the threading note on the class. + StateProvider p = provider; if (p != null) { try { // Before the routes, so a form the route table is about to build can read what @@ -1093,28 +711,21 @@ private static boolean restoreOnEdt(AppState state, long account, long lifecycle // documented shape -- would leave the application on no screen at all. return false; } + // Applying a state is not the user navigating, and the difference is not cosmetic. The + // rebuilt stack reaches routeStackChanged(), which checkpoints, which republishes what we + // just received under THIS device's id and a fresh sequence. The originating device then + // cannot recognize its own work -- it arrives as a foreign device's state -- so it + // restores it and republishes in turn, and the two bounce the same stack back and forth, + // re-navigating the user on every poll. 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; - } + applyingRestore = true; try { shown = Navigation.restoreStack(routes); } catch (Throwable t) { Log.e(t); shown = false; } finally { - synchronized (STATE_LOCK) { - applyingRestore = false; - } + applyingRestore = false; } if (shown) { // Locally, and only locally. Suppressing the checkpoint above also suppressed the @@ -1140,132 +751,80 @@ private static boolean restoreOnEdt(AppState state, long account, long lifecycle /// 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 (relay == null || !enabled || !Display.isInitialized()) { + return; } - if (!Display.isInitialized()) { + if (polling) { + // One fetch at a time. Two overlapping GETs can return DIFFERENT documents -- a relay + // holds one per user and the other device may replace it between them -- and nothing + // downstream re-orders the answers: lastSeen is keyed by ORIGINATING device, so a + // response that left first and arrived second passes deduplication on its own key and + // puts the older screen over the newer one. + // + // Remembered rather than dropped: an application that polls on reconnect while a + // resume poll is still in flight is asking a real question. + pollAgain = true; return; } - // 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; + startPoll(); + } + + /// Starts the one fetch worker. Called on the EDT; the worker touches nothing. + /// + /// NOT preceded by a publish. A relay holds one document per user, so a POST that reaches the + /// endpoint before this GET erases the other device's state -- and the GET then returns this + /// device's own echo, which deliver() drops, so the remote update is never seen at all. + /// Anything owed is sent when the fetch finishes, which is the only ordering that both sends + /// what is owed and reads what is there. + private static void startPoll() { + final StateRelay r = relay; + if (r == null || !Display.isInitialized()) { + return; } + final int session = relaySession; + polling = true; Display.getInstance().startThread(new Runnable() { @Override public void run() { + // Off the EDT because fetch() blocks, and touching NOTHING: the relay came in as + // a local and the answer goes back through the event queue. + AppState fetched = null; 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; - } - } + fetched = r.fetch(); } 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; - } } + final AppState result = fetched; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + pollFinished(result, session); + } + }); } }, "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 long delivery; - 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; - // The DELIVERY generation as well. accountEra moves on clear() and setRelay() but NOT - // on disable(), so an application that disabled and re-enabled while a fetch was in - // flight had the answer admitted into the new run: the account era still matched, and - // the admission then stamped it with the CURRENT deliveryEra -- which is precisely the - // rejection the lifecycle generation exists to perform. - delivery = deliveryEra; - r = relay; - if (r == null) { - return; - } - } - AppState fetched = null; - try { - fetched = r.fetch(); - } catch (Throwable t) { - Log.e(t); + /// A fetch has come back. On the EDT, where every field below is owned. + private static void pollFinished(AppState fetched, int session) { + if (session != relaySession) { + // A clear(), a setRelay() or a reset() happened while this was in flight. The answer + // belongs to the endpoint or the account that has since gone away: delivering it would + // restore the previous account's work into the next account's session, and the flags + // were already reset by whoever ended the session. return; } - if (fetched == null) { + polling = false; + if (fetched != null) { + admit(fetched); + } + if (pollAgain) { + pollAgain = false; + startPoll(); 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, delivery); + // Owed work goes out AFTER the fetch, never before it. + startPublisher(); } /// Forgets everything: the stored checkpoint, any parked arrival, the activity advertised to @@ -1279,37 +838,17 @@ private static void pollOnce() { /// 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++; - } + parked = null; + dirty = false; + // Anything queued for the relay belonged to the account that just signed out, and a relay + // reads its credentials when the request runs rather than when it was queued -- so a state + // left here would have gone out under the NEXT account's token. The session is ended too, + // so a fetch already in flight is not delivered into the account that just signed in. + // + // The one thing this cannot recall is a request already on the wire. Nothing in this + // process can; what it can do is make sure nothing follows it. + endRelaySession(); + lastSeen.clear(); // 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. @@ -1324,6 +863,20 @@ private static void clearLocked() { } } + /// Ends the current relay session: nothing queued goes out, and nothing in flight comes back. + /// + /// The counter is what makes an in-flight round trip harmless without any locking. A worker + /// carries the session it started in, and its completion -- which runs on the EDT -- returns + /// early when the session has moved on, so the flags reset here stay reset. + private static void endRelaySession() { + relaySession++; + pendingPublish = null; + publishing = false; + polling = false; + pollAgain = false; + publishRequested = false; + } + // ------------------------------------------------------------------ // Internals // ------------------------------------------------------------------ @@ -1423,191 +976,120 @@ private static void clearContinuation() { /// 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. + /// True while the publish worker is out. One at a time, so the relay's single document is + /// written in the order the checkpoints happened: two workers racing the same endpoint could + /// leave the older state stored last, and the user's other device then fetches work they had + /// already moved past. private static boolean publishing; - /// 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. + /// True while a relay fetch is out; `pollAgain` records a poll asked for during one. private static boolean polling; private static boolean pollAgain; - /// True when someone asked for a publisher while one was already running. + /// True when a publisher was wanted while one was already out. /// - /// 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. + /// The publisher does not retry in a loop -- one attempt per change, rather than a spin + /// against an endpoint that is down -- but a request that arrived DURING an attempt is a new + /// signal rather than a spin, and pollRelay() on reconnect is exactly that. Without it, an + /// application that reconnects while the failing attempt is still on the wire had its + /// reconnect forgotten, and the retained state waited for some later checkpoint. private static boolean publishRequested; - /// Hands a state to the relay, 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. + /// Hands a state to the relay, one at a time. private static void publishToRelay(AppState state) { - if (!Display.isInitialized()) { + if (!Display.isInitialized() || relay == null) { return; } - synchronized (STATE_LOCK) { - if (relay == null) { - return; - } - pendingPublish = state; - } + // A slot rather than a queue: a publish REPLACES what the relay holds, so an older state + // waiting behind a newer one has nothing to add. + pendingPublish = state; startPublisher(); } - /// Starts the single publisher, if there is work and nobody is doing it. + /// Starts the one publish worker, if there is work and nobody is doing it. Called on the EDT. /// - /// Separate from `publishToRelay` because a checkpoint is not the only thing that should - /// start one. A state retained after a failed send 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. + /// Separate from `publishToRelay` because a checkpoint is not the only thing that should start + /// one: a state retained after a failed send is sent by whatever finishes next -- a poll, or + /// the following checkpoint -- rather than sitting in the slot forever. private static void startPublisher() { - if (!Display.isInitialized()) { + if (!Display.isInitialized() || relay == null) { 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; + if (publishing) { + // Asked BEFORE the empty-slot check, which is the whole point of the flag. A worker + // that is out has already taken the state out of the slot, so the slot is empty + // exactly when this signal matters -- and testing it first threw the reconnect away + // and left the state that worker is about to fail on waiting for a later checkpoint. + publishRequested = true; + return; + } + if (pendingPublish == null) { + return; } + if (polling) { + // A GET is outstanding. The relay holds ONE document per user, so a POST that lands + // before the answer overwrites the other device's state -- and the GET then reads back + // our own write, so the remote update is never seen. pollFinished() starts a publisher + // when the fetch is done. + return; + } + final StateRelay r = relay; + final AppState next = pendingPublish; + final int session = relaySession; + pendingPublish = null; + publishing = true; Display.getInstance().startThread(new Runnable() { @Override public void run() { + // Off the EDT because publish() blocks, and touching NOTHING: the relay and the + // state came in as locals and the outcome goes back through the event queue. + boolean sent = true; 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); + r.publish(next); + } catch (Throwable t) { + Log.e(t); + sent = false; } + final boolean ok = sent; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + publishFinished(next, session, ok); + } + }); } }, "Continuity relay publish").start(); } + /// A publish has come back. On the EDT, where every field below is owned. + private static void publishFinished(AppState sent, int session, boolean ok) { + if (session != relaySession) { + // The session ended while this was in flight; endRelaySession() has already reset the + // flags and the state this carried belongs to an account that has signed out. + return; + } + publishing = false; + if (!ok && pendingPublish == null) { + // Kept, not dropped -- StateRelay.publish documents that the framework holds a failed + // state for the next attempt, and dropping it meant the last checkpoint before the + // network went away never reached the other device at all. Put back only when nothing + // newer is queued, since a newer state supersedes it entirely. + pendingPublish = sent; + if (!publishRequested) { + // Stood down rather than retried: one attempt per change, not a spin against an + // endpoint that is down. The next checkpoint or poll starts the next one. + return; + } + } + // Somebody asked for a publisher while this attempt was in flight -- an application + // calling pollRelay() on reconnect is the ordinary case. Consumed rather than looped on, + // so it is one extra attempt per request. + publishRequested = false; + // Drains anything queued while this was in flight, and is a no-op when nothing is. + startPublisher(); + } + /// The activity type this app publishes and answers to, which is the app's package name /// followed by `.continuity`. /// @@ -1633,172 +1115,93 @@ public static String getActivityType() { } /// 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) { - deliver(state, pollEra, NO_ERA); - } - - /// As above, also rejecting a state fetched in an earlier run of the framework. - static void deliver(final AppState state, final long pollEra, final long pollDelivery) { + /// The one method here that is called from a foreign thread -- a port hands a continuation + /// over on the platform's own thread -- so it does the marshalling and everything downstream + /// is ordinary EDT code. + static void deliver(final AppState state) { if (state == null) { return; } - final long observedAccount; - final long observedDelivery; - synchronized (STATE_LOCK) { - if (!enabled) { - return; - } - if ((pollEra != NO_ERA && pollEra != accountEra) - || (pollDelivery != NO_ERA && pollDelivery != deliveryEra)) { - return; + if (!Display.isInitialized()) { + // No event thread yet, so there is nothing to marshal to and nothing running that + // could be racing this. Held for the EDT that is about to start. + parked = state; + return; + } + // ALWAYS queued, even when the caller is already on the EDT. Admission and dispatch are + // deliberately separate turns -- see admit() -- and running one caller's arrival inline + // while another's is queued would put them in different orders depending on who called. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + admit(state); } - // OBSERVED here, required unchanged below. A caller that supplies no generation -- a - // platform continuation arrives that way, since the OS has no notion of our eras -- - // skipped both predicates entirely, so a clear() or disable() landing between these - // two locked blocks let the arrival through and stamped it with the NEW deliveryEra. - // The previous account's state then reached the listeners and the screen after - // logout. What every caller can be held to, era or not, is that nothing changed while - // this was deciding. - observedAccount = accountEra; - observedDelivery = deliveryEra; + }); + } + + /// Decides whether an arrival is worth acting on, and records it. On the EDT. + /// + /// The dispatch is a SECOND turn rather than the rest of this one. Two states from the same + /// device can be in flight together -- a continuation and a relay poll routinely carry + /// different sequences -- and admitting both before either is applied is what lets the older + /// one notice it has been superseded and stand down. Applying inline instead walked the user + /// through the stale screen on the way to the fresh one. + private static void admit(final AppState state) { + if (!enabled) { + return; } 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. + // A relay hands back whatever it still holds, which can be days old, and an expired + // checkout or booking hold that auto-restored is the exact harm setMaxAge exists to + // prevent. Dropped before lastSeen records it, so the sequence stays free for a + // fresher state from the same device. return; } - final long era; - synchronized (STATE_LOCK) { - // Re-asked under the SAME hold that records the mark, so a logout -- or a disable - // and re-enable -- between the check above and this one cannot slip an old state past - // both. Against the OBSERVED generation, so this holds for a platform arrival that - // carried no era of its own; getDeviceId() and isTooOld() above each take the lock - // and release it, so the gap is real time. - if (observedAccount != accountEra || observedDelivery != deliveryEra) { - 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); - } + Long seen = lastSeen.get(state.getDeviceId()); + if (seen != null && seen.longValue() >= state.getSequence()) { + // Delivered twice, which happens routinely: a continuation and a relay poll can carry + // the same state. return; } + lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); 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); + if (!enabled) { + // disable() between the two turns. Arriving states are ignored from the + // moment it is called, including the ones already admitted. + return; } - } - }); - } - - /// 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. + Long newest = lastSeen.get(state.getDeviceId()); + if (newest == null || newest.longValue() != state.getSequence()) { + // Superseded while this was queued: a newer state from the same device was + // admitted behind it. Applying this one now would move the user backwards. return; } + dispatch(state); } - 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; - } + /// Applies an arrival: offers it to the listeners, then restores or parks it. + private static void dispatch(AppState state) { if (Display.getInstance().getCurrent() == null) { // A continuation can cold-launch the app, and both Apple delegates hand it over while // init/start are still queued. Restoring against no form at all would run the route // table into a display that is not ready, so it waits -- bounded, because a launch // that never produces a form is broken and jumping the user minutes later is worse // than doing nothing. - park(state, era); + park(state); 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); - } + List snapshot = new ArrayList(listeners); for (ContinuityListener l : snapshot) { boolean accepted; try { @@ -1813,36 +1216,33 @@ private static void dispatchLocked(AppState state, long era) { return; } } - boolean auto; - synchronized (STATE_LOCK) { - auto = autoRestore; - } - if (auto) { + if (autoRestore) { 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 + // it at admission meant a process killed before this 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); + parked = state; } } - private static void park(final AppState state, long era) { - setParked(state, era); - synchronized (STATE_LOCK) { - if (waitingForWindow) { - return; - } - waitingForWindow = true; + /// Holds a cold-launch arrival until the application has a form to restore into. + /// + /// The waiter is a thread only because there is nothing on the EDT to wait on -- no form + /// exists yet, so there is no timer to bind to. It touches no field of this class: it sleeps, + /// and hands the decision back to the event thread. + private static void park(AppState state) { + parked = state; + if (waitingForWindow) { + return; } + waitingForWindow = true; Display.getInstance().startThread(new Runnable() { @Override public void run() { @@ -1851,41 +1251,40 @@ public void run() { try { Thread.sleep(100); } catch (InterruptedException err) { + Thread.currentThread().interrupt(); 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); - } + windowWaitFinished(); } }); } }, "Continuity window wait").start(); } + /// The cold-launch wait is over. On the EDT. + private static void windowWaitFinished() { + waitingForWindow = false; + if (Display.getInstance().getCurrent() == null) { + // Still no form after the whole wait. The state stays parked, so an application that + // gets going later can still ask for it through getRestorableState(). + return; + } + // Taken and cleared rather than compared against the state the waiter was started for. A + // newer arrival while it waited is the one worth showing. + AppState waiting = parked; + parked = null; + if (waiting != null) { + dispatch(waiting); + } + } + private static String loadDeviceId() { try { String id = Preferences.get(PREF_DEVICE_ID, null); @@ -1903,22 +1302,6 @@ private static String loadDeviceId() { } } - /// 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 -- @@ -1940,11 +1323,9 @@ private static void noteActedOn(AppState state) { // 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())); - } + 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 @@ -1994,24 +1375,7 @@ private static Map readSeen() { /// 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); - } + Map copy = new HashMap(lastSeen); while (copy.size() > MAX_SEEN) { String lowest = null; long lowestSeq = Long.MAX_VALUE; @@ -2033,7 +1397,7 @@ private static void rememberSeenLocked() { } sb.append(e.getKey()).append('|').append(e.getValue().longValue()); } - // ONLY the write is guarded. Iterating a generic map compiles to checkcasts, and a + // ONLY the write is wrapped. Iterating a generic map compiles to checkcasts, and a // catch(Throwable) around them is a handler ParparVM never runs -- its CHECKCAST expands // to nothing, so a failed cast hands the wrong object to the next instruction and crashes // natively instead. check-cast-semantics.sh refuses the shape, correctly: the only thing @@ -2064,13 +1428,9 @@ private static long nextSequence() { /// /// - `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) { + bridge = b; + bridgeOverridden = b != null; + if (b != null && enabled) { try { b.setCallback(new Callback()); } catch (Throwable t) { @@ -2103,9 +1463,7 @@ public static ContinuityBridge bridgeForSyncedStore() { /// 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; - } + storeCallbackInstalled = true; ContinuityBridge b = bridgeInternal(); if (b == null) { return; @@ -2121,18 +1479,14 @@ public static void installSyncedStoreCallback() { /// 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) { + // OR the store's own flag, not `enabled` alone. An application that only registers a + // SyncedStore listener deliberately leaves continuity off -- a key/value store is not + // consent to broadcast a route stack -- so testing `enabled` here meant the simulator's + // capability menu, which swaps the bridge and calls this, left the replacement with no + // callback at all and every later "Change the Synced Store" item silently did nothing. + // That is the documented sync-only workflow breaking on the first use of an unrelated + // menu item. + if (!enabled && !storeCallbackInstalled) { return; } ContinuityBridge b = bridgeInternal(); @@ -2147,10 +1501,8 @@ public static void refreshBridge() { } static ContinuityBridge bridgeInternal() { - synchronized (STATE_LOCK) { - if (bridgeOverridden) { - return bridge; - } + if (bridgeOverridden) { + return bridge; } if (!Display.isInitialized()) { return null; @@ -2164,92 +1516,56 @@ static ContinuityBridge bridgeInternal() { } /// 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; + /// A live relay worker is not waited for. Ending the session is enough: the worker carries the + /// session it started in, so its completion returns early and touches none of the flags reset + /// here. + static void reset() { + listeners.clear(); + lastSeen.clear(); + endRelaySession(); + 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; + } + + /// The store notification, as a constant rather than an anonymous class per callback. + /// + /// It captures nothing -- notifyChanged() is static -- so an inner class would hold its + /// enclosing Callback alive for no reason, which SpotBugs reports as + /// SIC_INNER_SHOULD_BE_STATIC_ANON. + private static final Runnable NOTIFY_STORE = new Runnable() { + @Override + public void run() { + com.codename1.continuity.sync.SyncedStore.notifyChanged(); } - } + }; /// The inbound seam handed to the port's bridge. static final class Callback implements ContinuityCallback { @Override public boolean continuationReceived(String activityType, Map userInfo) { - 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. + // Called on the platform's thread, and answered from the activity type ALONE. The + // port needs a synchronous yes or no -- its answer decides whether the activity falls + // through to another handler -- and the type is a pure function of the package name, + // so nothing here has to read framework state from a foreign thread. `enabled` is + // asked on the event thread, in admit(): an activity of this app's own type is + // ours to claim whether or not the framework happens to be on, and claiming it is what + // keeps it from being offered to a handler that would do nothing with it. + if (activityType == null || !activityType.equals(getActivityType())) { return false; } AppState state = StateCodec.fromMap(userInfo); @@ -2262,7 +1578,14 @@ public boolean continuationReceived(String activityType, Map use @Override public void syncedStoreChanged() { - com.codename1.continuity.sync.SyncedStore.notifyChanged(); + // Arrives on the platform's thread, like continuationReceived. The listeners are + // application code and run on the event thread, as every other callback in the + // toolkit does. + if (!Display.isInitialized() || Display.getInstance().isEdt()) { + com.codename1.continuity.sync.SyncedStore.notifyChanged(); + return; + } + Display.getInstance().callSerially(NOTIFY_STORE); } } } diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 48a149ee04e..38ab21e9645 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -25,7 +25,6 @@ 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; @@ -67,10 +66,6 @@ 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() { } @@ -200,15 +195,8 @@ public static String[] keys() { /// /// - `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); - } + 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 @@ -231,43 +219,26 @@ public static void addChangeListener(SyncedStoreListener l) { /// /// - `l`: the listener public static void removeChangeListener(SyncedStoreListener l) { - synchronized (LISTENER_LOCK) { - listeners.remove(l); - } + listeners.remove(l); } /// Internal. Invoked by the continuity framework when a port reports that the store changed /// underneath the app. Application code registers a `SyncedStoreListener` instead. public static void notifyChanged() { - synchronized (LISTENER_LOCK) { - if (listeners.isEmpty()) { - return; + // On the EDT: Continuity.Callback marshals the port's notification before it gets here. + // Copied before iterating, because a listener that reacts to a change by unregistering + // itself is ordinary and would otherwise mutate the list being walked. + List snapshot = new ArrayList(listeners); + // The element cast the compiler inserts sits in the loop header, outside the handler -- + // a failed cast does not throw on the iOS virtual machine, so a handler wrapped around + // one could not run there anyway. + for (SyncedStoreListener l : snapshot) { + try { + l.storeChanged(); + } catch (Throwable t) { + Log.e(t); } } - 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) { @@ -282,8 +253,6 @@ private static ContinuityBridge bridge() { /// Test seam: forgets every registered listener. static void reset() { - synchronized (LISTENER_LOCK) { - listeners.clear(); - } + listeners.clear(); } } diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index c184c3480fc..ec7c651bd4d 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -52,24 +52,9 @@ public class LocalContinuityBridge implements ContinuityBridge { /// 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(); - + // EDT-owned. Everything here runs on the Codename One event thread: the framework calls in + // from there, and the simulator's "Simulate ->" items reach this class through + // SimulatorHookLoader, which dispatches every hook with Display.callSeriallyAndWait. private ContinuityCallback callback; private String publishedType; private String publishedTitle; @@ -77,9 +62,7 @@ public class LocalContinuityBridge implements ContinuityBridge { @Override public void setCallback(ContinuityCallback c) { - synchronized (lock) { - callback = c; - } + callback = c; } @Override @@ -92,22 +75,16 @@ 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; - } + publishedType = activityType; + publishedTitle = title; + publishedInfo = copy; } @Override public void clearContinuation() { - synchronized (lock) { - publishedType = null; - publishedTitle = null; - publishedInfo = null; - } + publishedType = null; + publishedTitle = null; + publishedInfo = null; } /// The activity type currently advertised, or null when nothing is. @@ -116,9 +93,7 @@ public void clearContinuation() { /// /// the type public String getPublishedType() { - synchronized (lock) { - return publishedType; - } + return publishedType; } /// The label currently advertised, or null. @@ -127,9 +102,7 @@ public String getPublishedType() { /// /// the label public String getPublishedTitle() { - synchronized (lock) { - return publishedTitle; - } + return publishedTitle; } /// The payload currently advertised, or null when nothing is. @@ -138,12 +111,7 @@ public String getPublishedTitle() { /// /// 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); - } + return publishedInfo == null ? null : new HashMap(publishedInfo); } /// Delivers the currently advertised activity back to the app as though it had arrived from @@ -157,19 +125,12 @@ public Map getPublishedInfo() { /// /// 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); + if (publishedType == null || publishedInfo == null) { + return false; } + Map copy = new HashMap(publishedInfo); copy.put("device", "simulated-device"); - return simulateArrival(type, copy); + return simulateArrival(publishedType, copy); } /// Delivers an arbitrary activity, for tests that build their own. @@ -183,10 +144,7 @@ public boolean simulateArrival() { /// /// true when the app claimed it public boolean simulateArrival(String activityType, Map userInfo) { - ContinuityCallback c; - synchronized (lock) { - c = callback; - } + ContinuityCallback c = callback; if (c == null) { return false; } @@ -209,28 +167,15 @@ public boolean isSyncedStoreSupported() { @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)); + Preferences.set(PREFIX + key, value); + 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? + return value.equals(Preferences.get(PREFIX + key, null)); } @Override @@ -240,32 +185,23 @@ public String syncedStoreGet(String key) { @Override public void syncedStoreRemove(String key) { - synchronized (INDEX_LOCK) { - Preferences.delete(PREFIX + key); - List keys = indexKeys(); - if (keys.remove(key)) { - writeIndex(keys); - } + 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()]); - } + 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; - } + ContinuityCallback c = callback; if (c == null) { return; } diff --git a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java index 20e29091cdf..afeb22047e7 100644 --- a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java @@ -126,6 +126,14 @@ public String[] syncedStoreKeys() { @Override public void run() { try { + if (!Continuity.isCheckpointPending()) { + // Asked HERE rather than before the hop. The framework writes through as the + // user navigates, so by the time Android says it may kill the process there + // is usually nothing owed -- but the answer lives in EDT-owned fields, and + // reading it from Android's main thread was the one place this port reached + // into the framework's state from off the event thread. + return; + } Continuity.checkpoint(); } catch (Throwable t) { Log.e(t); @@ -133,6 +141,18 @@ public void run() { } }; + /// The resume poll, as a constant for the reason CHECKPOINT is one. + private static final Runnable POLL = new Runnable() { + @Override + public void run() { + try { + Continuity.pollRelay(); + } catch (Throwable t) { + Log.e(t); + } + } + }; + /// Flushes the checkpoint when the platform says the process may be killed. /// /// `onSaveInstanceState` is the right hook and `onStop` is not. Android calls this one *before* @@ -155,8 +175,12 @@ public void onResume() { // here is what makes "picked it up on the iPad, opened the phone" work: the poll is a // background request that returns immediately and does nothing at all when no relay is // installed. + // + // Marshalled, and NOT waited for. This runs on Android's main thread and pollRelay() + // reads EDT-owned fields to decide whether a fetch is already out; blocking here for + // a request that returns immediately anyway would only slow every resume. try { - Continuity.pollRelay(); + Display.getInstance().callSerially(POLL); } catch (Throwable t) { Log.e(t); } @@ -173,23 +197,15 @@ 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. + // captured beside is an EDT-owned list. Reading either from here would race the + // running application, so the whole decision -- including whether anything is + // owed at all -- is made on the other side of the hop. // - // Waiting blocks Android's main thread, which is why it is behind the check - // above: it is paid only when there is genuinely something to save, not on every - // suspend. + // Waiting blocks Android's main thread. That is the cost of a guaranteed flush at + // the last callback before the process can be reclaimed, and it is bounded. Display.getInstance().callSeriallyAndWait(CHECKPOINT, CHECKPOINT_TIMEOUT_MILLIS); } catch (Throwable t) { // Never allowed to escape. This runs on Android's main thread inside a platform diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 9f2e81c4ef7..53238a95f40 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -296,7 +296,33 @@ - (BOOL)cn1ContinueUserActivity:(NSUserActivity *)userActivity // // 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"]) { + // + // Matched EXACTLY, against the type the BUILD resolved. A suffix test claimed any activity + // whose type merely ended in ".continuity" -- a donated App Intent with such an id, say -- + // and on a cold launch that arrival was parked and reported handled before anything could + // tell it apart, so it never reached the intents dispatcher below and nothing rerouted it + // once initialization revealed the mismatch. + // + // Read from the plist rather than derived from [[NSBundle mainBundle] bundleIdentifier]. + // Deriving it looks equivalent and is wrong on the Mac slice: + // DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the Catalyst bundle id + // ".maccatalyst", so the derived type would be ".maccatalyst.continuity" + // while the type declared in NSUserActivityTypes and published by every device is + // ".continuity" -- Handoff silently dead on Catalyst, which is the Mac-to-iPhone + // case this feature is for. IPhoneBuilder writes CN1ContinuityActivityType beside that + // declaration from the one value it resolved, and the Mac plist is generated from the + // finished iOS one, so both slices read the same string. + // + // Absent means no builder that knows this feature generated the project, so the activity is + // left to the branch below rather than guessed at. + NSString *cn1ContinuityExpected = [[NSBundle mainBundle] + objectForInfoDictionaryKey:@"CN1ContinuityActivityType"]; + if (![cn1ContinuityExpected isKindOfClass:[NSString class]] + || [cn1ContinuityExpected length] == 0) { + cn1ContinuityExpected = nil; + } + if (userActivity != nil && cn1ContinuityExpected != nil + && [userActivity.activityType isEqualToString:cn1ContinuityExpected]) { NSString *payload = nil; if (userActivity.userInfo != nil && [NSJSONSerialization isValidJSONObject:userActivity.userInfo]) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 7943d21b296..31e620a2957 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -25,6 +25,7 @@ import com.codename1.continuity.spi.ContinuityCallback; import com.codename1.io.JSONParser; import com.codename1.io.Log; +import com.codename1.ui.Display; import java.util.HashMap; import java.util.Map; @@ -42,18 +43,15 @@ /// 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 framework's inbound seam, owned by the event thread. /// - /// 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(); - + /// The platform hands a continuation over on a thread of its own, so `nativeContinuation` + /// marshals with `com.codename1.ui.Display#callSerially` and everything below it is ordinary + /// EDT code. The one arrival that cannot be marshalled is the one that beats the event thread + /// into existence -- a cold launch delivers from `willConnectToSession`, before Display is + /// initialized -- and that one is parked on the platform's thread. It needs no guard either: + /// the writes happen before the EDT is started, and starting a thread publishes everything + /// written before it. private static ContinuityCallback callback; /// Written once by the class initializer, which every thread's first touch of this class @@ -78,46 +76,31 @@ 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; + callback = c; + String type = pendingType; + String json = pendingJson; + if (c == null || type == null) { + return; } - 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; - } - } - } + // A continuation that cold-launched the app reaches this class before the application's + // init() has called Continuity.enable(), which is what installs the callback -- the scene + // delegate hands it over from willConnectToSession, which runs first. Delivered now + // instead of dropped, which is what the whole feature is for. + boolean claimed = false; + try { + claimed = c.continuationReceived(type, parse(json)); + } catch (Throwable t) { + Log.e(t); + } + if (claimed) { + // Cleared only once a callback has actually TAKEN it. The callback can legitimately + // decline: SyncedStore.addChangeListener installs one without enabling continuity -- + // a key/value store is not consent to restore a route stack -- and on a cold launch + // that can happen before the application's init() calls enable(). Clearing regardless + // meant the launch activity was erased by the refusal, and the enable() moments later + // had nothing left to deliver. + pendingType = null; + pendingJson = null; } } @@ -131,79 +114,61 @@ public static boolean nativeContinuation(String activityType, String userInfoJso if (dceGuard) { return false; } - ContinuityCallback c; - synchronized (LOCK) { - c = callback; + // Answered on the platform's thread, from the activity type alone, because the delegate + // needs the answer now: it decides whether the activity falls through to the intents + // branch beside it, and one this app is about to act on must not. + // + // Declined only on a POSITIVE mismatch. Asking for the expected type can fail this early, + // before the stub has published package_name, and treating "cannot tell" as "not ours" + // would decline the framework's own cold launch -- the one case the whole feature exists + // for. Belt and braces in any case: the delegate already matches the exact type the build + // resolved, and this guards the app whose generated project predates that key. + String expected = expectedTypeOrNull(); + if (expected != null && !expected.equals(activityType)) { + return false; } - if (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; - } - } + if (!Display.isInitialized()) { + // The event thread does not exist yet, so there is nothing to marshal to. Parked here + // and drained by setCallback; the EDT is started after these writes, which publishes + // them to it. + pendingType = activityType; + pendingJson = userInfoJson; + return true; } + final String type = activityType; + final String json = userInfoJson; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + deliverOnEdt(type, json); + } + }); + return true; + } + + /// Hands an arrival to the framework, or holds it. On the event thread. + private static void deliverOnEdt(String activityType, String userInfoJson) { + ContinuityCallback c = callback; boolean claimed = false; - try { - claimed = c.continuationReceived(activityType, parse(userInfoJson)); - } catch (Throwable t) { - Log.e(t); - return false; + if (c != null) { + try { + claimed = c.continuationReceived(activityType, parse(userInfoJson)); + } catch (Throwable t) { + Log.e(t); + } } if (claimed) { - return true; + return; } - // DECLINED, which is not the same as "not ours". A callback is installed by + // Held, because DECLINED is not the same as "not ours". A callback is installed by // SyncedStore.addChangeListener() as well as by Continuity.enable(), and the store // listener deliberately leaves continuity disabled -- so an app that registers one before // enabling has a live callback that answers false to everything. A continuation arriving // in that window used to be handed over, refused, and dropped, and the enable() moments // later had nothing to recover: registering an unrelated store listener turned a parked // cold-launch continuation into a lost one. - // - // Held on the same rule the no-callback path uses: 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 discard the framework's - // own cold launch -- the one case this exists for. - String stillExpected = expectedTypeOrNull(); - if (stillExpected != null && !stillExpected.equals(activityType)) { - return false; - } - synchronized (LOCK) { - pendingType = activityType; - pendingJson = userInfoJson; - } - return true; + pendingType = activityType; + pendingJson = userInfoJson; } /// The synced store changed on another of the user's devices. @@ -211,18 +176,23 @@ public static void nativeSyncedStoreChanged() { if (dceGuard) { return; } - ContinuityCallback c; - synchronized (LOCK) { - c = callback; - } - if (c == null) { + if (!Display.isInitialized()) { return; } - try { - c.syncedStoreChanged(); - } catch (Throwable t) { - Log.e(t); - } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + ContinuityCallback c = callback; + if (c == null) { + return; + } + try { + c.syncedStoreChanged(); + } catch (Throwable t) { + Log.e(t); + } + } + }); } /// This app's continuity activity type, or null when it cannot be determined yet. @@ -232,7 +202,7 @@ public static void nativeSyncedStoreChanged() { /// mismatch that reads as certainty. private static String expectedTypeOrNull() { try { - String pkg = com.codename1.ui.Display.getInstance().getProperty("package_name", null); + String pkg = Display.getInstance().getProperty("package_name", null); if (pkg == null || pkg.length() == 0) { return null; } diff --git a/docs/website/workers/exp004-telemetry/test/integration.mjs b/docs/website/workers/exp004-telemetry/test/integration.mjs index 9aa0791f441..132adcd7d23 100644 --- a/docs/website/workers/exp004-telemetry/test/integration.mjs +++ b/docs/website/workers/exp004-telemetry/test/integration.mjs @@ -167,28 +167,82 @@ async function expectStatus(response, expected, what) { `${what}: expected ${expected}, got ${response.status} with body ${body}`); } +/* + * True when a response is the edge saying it has no route for us yet, rather + * than our worker answering. + * + * The distinction is the one collectNotReady already draws and for the same + * reason: a 404 carrying Cloudflare's HTML error page is produced BEFORE the + * worker runs, so nothing was read, counted or deduplicated. A 5xx is not this + * -- bindings that are still initializing answer after the request reached the + * handler -- so it is deliberately not included here. + */ +async function isUnroutedEdgeResponse(response) { + if (response.status !== 404) { + return false; + } + const contentType = response.headers.get("content-type") || ""; + return !contentType.includes("application/json"); +} + +/* + * Posts, retrying only while the edge has not routed us yet. + * + * waitFor gates the suite on one healthy round, and that is not enough on its + * own: propagation is per-request and neither monotonic nor global, so a later + * POST can still land on an edge node that has not caught up. That is what + * failed here -- the "unknown event name" assertion got Cloudflare's 404 page + * instead of the worker's 400, on a run whose readiness probe had already + * passed. + * + * Safe to repeat, and ONLY in this exact case. readSnapshot retries because a + * GET changes nothing; the objection to retrying a POST is that it could count + * twice. An unrouted 404 cannot: the request never reached the worker. Any + * response the worker itself produced -- including every rejection the suite + * asserts on -- is returned untouched on the first attempt. + */ async function post(baseUrl, body, origin = "https://www.codenameone.com") { - return fetch(`${baseUrl}/api/exp004/collect`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: origin, - "Sec-Fetch-Site": "same-origin", - }, - body: JSON.stringify({ occurred_at: Date.now(), ...body }), - }); + const payload = JSON.stringify({ occurred_at: Date.now(), ...body }); + const deadline = Date.now() + 30_000; + for (;;) { + const response = await fetch(`${baseUrl}/api/exp004/collect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: origin, + "Sec-Fetch-Site": "same-origin", + }, + body: payload, + }); + if (!await isUnroutedEdgeResponse(response) || Date.now() >= deadline) { + return response; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } } async function enroll(baseUrl, sessionKey, arm) { - const response = await fetch(`${baseUrl}/api/exp004/session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://www.codenameone.com", - "Sec-Fetch-Site": "same-origin", - }, - body: JSON.stringify({ session_key: sessionKey, arm }), - }); + // Same unrouted-edge retry as post(): enrolment is a POST on a second route, + // and a route propagates on its own schedule, so gating on /collect being + // live says nothing about /session. + const body = JSON.stringify({ session_key: sessionKey, arm }); + const deadline = Date.now() + 30_000; + let response; + for (;;) { + response = await fetch(`${baseUrl}/api/exp004/session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://www.codenameone.com", + "Sec-Fetch-Site": "same-origin", + }, + body, + }); + if (!await isUnroutedEdgeResponse(response) || Date.now() >= deadline) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } await expectStatus(response, 201, `enroll ${sessionKey} into ${arm}`); const payload = await response.json(); assert.match(payload.submission_token, /^[0-9a-f-]{36}$/); diff --git a/maven/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 21c55f57fc9..6c7017d8a2c 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 @@ -11671,6 +11671,53 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S /// supported configuration silently useless: the content was findable, and tapping it did /// nothing, because without this key iOS never continues the activity and /// nativeSpotlightItemSelected is never reached. + /// Declares the continuity activity type where the app's own native code can read it. + /// + /// `NSUserActivityTypes` tells iOS which activities to offer; this tells the delegate which + /// of them is this framework's. It has to be a value the BUILD resolved, because the delegate + /// decides before any Java is running and cannot ask the framework -- and the obvious + /// substitute is wrong on the Mac slice: `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER` makes + /// the Catalyst bundle id `.maccatalyst`, so a type derived there from the bundle id + /// would be `.maccatalyst.continuity` while every device publishes + /// `.continuity`. The Mac plist is generated from the finished iOS one, so writing + /// it once here gives both slices the same string. + /// + /// #### Parameters + /// + /// - `inject`: the plist fragment being built + /// + /// - `continuityType`: the resolved activity type, or null when the app does not use the + /// feature + /// + /// #### Returns + /// + /// the fragment, with the key added when one is needed + /// + /// #### Throws + /// + /// - `BuildException`: when the project injects a different type of its own + static String withContinuityActivityType(String inject, String continuityType) + throws BuildException { + if (continuityType == null) { + return inject; + } + // Refused rather than left alone when it disagrees, exactly as CN1DocumentsAppGroup is, + // because this is not a hint. An injected key naming a different type has the delegate + // rejecting the application's own continuations while iOS goes on offering them. + String injected = topLevelPlistString(inject, "CN1ContinuityActivityType"); + if (injected != null && !injected.equals(continuityType)) { + throw new BuildException("ios.plistInject sets CN1ContinuityActivityType to '" + + injected + "' while this build publishes '" + continuityType + + "'. The application would refuse its own continuations. Remove the " + + "injected key."); + } + if (declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) { + return inject; + } + return inject + "\nCN1ContinuityActivityType" + + xmlEscape(continuityType) + ""; + } + static String withSpotlightContinuation(String inject, boolean usesIntents) { if (!usesIntents || inject.contains("CoreSpotlightContinuation")) { return inject; @@ -14577,6 +14624,17 @@ public boolean accept(File file, String string) { intentsManifest, continuityActivityType); } } + + // The resolved type, written where the NATIVE side can read it. The delegate decides + // whether an arriving NSUserActivity is this framework's before any Java is running, and + // deriving that from [[NSBundle mainBundle] bundleIdentifier] is WRONG on the Mac slice: + // DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes that id ".maccatalyst", so + // the derived type would be ".maccatalyst.continuity" while the type this build + // declares above and the app publishes is ".continuity". Handoff would be dead + // on Catalyst, which is the Mac-to-iPhone case the feature exists for. One value, decided + // here, read by both slices: the Mac plist is generated from this finished one, so it + // carries the key unchanged. + inject = withContinuityActivityType(inject, continuityActivityType); // CoreSpotlightContinuation is about Spotlight, not about App Intents, and gating it on // a declaration made an entire supported configuration silently useless: an app that // only calls Intents.index() declares no intent at all -- parseIntentsManifest treats a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java index 25aadead9cd..ce74bbc8387 100644 --- 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 @@ -32,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * {@code NSUserActivityTypes} has two contributors, and there can only be one key. @@ -78,6 +79,76 @@ private static int occurrences(String haystack, String needle) { return count; } + // ------------------------------------------------------------------ + // The type the native side reads + // ------------------------------------------------------------------ + + @Test + void theResolvedActivityTypeIsDeclaredForTheNativeSide() throws BuildException { + String out = IPhoneBuilder.withContinuityActivityType("", CONTINUITY_TYPE); + + assertTrue(out.contains("CN1ContinuityActivityType"), out); + assertTrue(out.contains("" + CONTINUITY_TYPE + ""), out); + assertEquals(1, occurrences(out, "CN1ContinuityActivityType"), + "the key must appear exactly once: " + out); + } + + @Test + void anAppThatDoesNotUseContinuityGetsNoSuchKey() throws BuildException { + String out = IPhoneBuilder.withContinuityActivityType("Otherx", + null); + + assertFalse(out.contains("CN1ContinuityActivityType"), out); + } + + /** + * The delegate compares the arriving activity type against this key. Deriving it natively from + * the bundle identifier instead looks equivalent and is wrong on the Mac slice, where + * DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the id "<package>.maccatalyst" -- + * so the derived type would be "<package>.maccatalyst.continuity" while every device + * publishes "<package>.continuity", and Handoff would be silently dead on Catalyst. + * The value written here is the package's, not the bundle's. + */ + @Test + void theDeclaredTypeIsThePackagesAndCarriesNoCatalystSuffix() throws BuildException { + String out = IPhoneBuilder.withContinuityActivityType("", CONTINUITY_TYPE); + + assertTrue(out.contains("com.example.app.continuity"), out); + assertFalse(out.contains("maccatalyst"), out); + } + + @Test + void anApplicationsOwnMatchingDeclarationIsLeftAlone() throws BuildException { + String existing = "CN1ContinuityActivityType" + CONTINUITY_TYPE + + ""; + + String out = IPhoneBuilder.withContinuityActivityType(existing, CONTINUITY_TYPE); + + assertEquals(existing, out, "a matching declaration must not be duplicated"); + assertEquals(1, occurrences(out, "CN1ContinuityActivityType"), out); + } + + /** + * A stale injected value is refused rather than left standing. It is not a build hint: the + * type is what NSUserActivityTypes declares and what every device publishes, so a fragment + * naming a different one has the delegate turning away the application's own continuations + * while iOS keeps offering them -- nothing fails and nothing is logged. + */ + @Test + void aDisagreeingInjectedTypeIsRefused() { + String existing = "CN1ContinuityActivityTypecom.other.app.continuity" + + ""; + + try { + IPhoneBuilder.withContinuityActivityType(existing, CONTINUITY_TYPE); + fail("a fragment naming a different activity type must not be accepted"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("CN1ContinuityActivityType"), + expected.getMessage()); + assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage()); + } + } + // ------------------------------------------------------------------ // Emitting the key // ------------------------------------------------------------------ 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 index edf37c3bb37..a7e0c9e4ee7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -92,6 +92,30 @@ public void clearFramework() { // Nothing happens until the application opts in // ------------------------------------------------------------------ + /// Runs a blocking wait OFF the event thread. + /// + /// Every relay round trip now finishes with a callSerially: the worker hands its answer back + /// to the event thread rather than touching framework state itself. A test that blocks the + /// EDT waiting for one therefore waits for a runnable queued behind its own wait, and the + /// harness reports "pendingSerialCalls=1". invokeAndBlock releases the EDT for the duration, + /// which is what an application doing a long wait does too. + private static void awaitOffEdt(Runnable r) { + Display.getInstance().invokeAndBlock(r); + } + + /// Sleeps without holding the event thread. See awaitOffEdt. + private static void pause(final long millis) { + awaitOffEdt(new Runnable() { + public void run() { + try { + Thread.sleep(millis); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + } + /** * The single most important property of this feature: an app that never touches it behaves * exactly as it always did. @@ -501,8 +525,12 @@ public void relayPublishesArriveInCheckpointOrder() { provider.saved.put("n", Integer.valueOf(i)); Continuity.checkpoint(); } - long newest = Continuity.getRestorableState().getSequence(); - r.awaitPublished(newest); + final long newest = Continuity.getRestorableState().getSequence(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitPublished(newest); + } + }); assertFalse(r.published.isEmpty(), "the relay saw nothing at all"); // Coalescing is allowed and expected -- what is not allowed is going backwards. @@ -529,7 +557,11 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { Continuity.checkpoint(); long failed = Continuity.getRestorableState().getSequence(); - r.awaitAttempts(1); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAttempts(1); + } + }); assertEquals(0, r.delivered.size(), "the first attempt was supposed to fail"); // Deliberately NOT another checkpoint. A checkpoint overwrites the pending slot with its @@ -545,13 +577,11 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { r.fail = false; long deadline = System.currentTimeMillis() + 3000L; while (r.delivered.isEmpty() && System.currentTimeMillis() < deadline) { + // pollRelay() on the EDT, the wait off it: the poll reads event-thread state to + // decide whether a fetch is already out, and the completion it is waiting for is a + // queued runnable. Continuity.pollRelay(); - try { - Thread.sleep(40); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - break; - } + pause(40L); } assertEquals(1, r.delivered.size(), "the retained state never reached the relay"); @@ -560,14 +590,16 @@ public void aFailedPublishKeepsTheStateForTheNextAttempt() { } /** - * 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. + * A fetch that was already on the wire when the user signed out must not be delivered into + * the session that follows it. The relay is held inside fetch(), clear() runs while it is + * held, and the answer it finally returns belongs to the account that has gone. + * + *

This is the one thing a relay round trip needs that a single-threaded framework cannot + * get for free: the request outlives the event-thread turn that started it. It is answered + * with a session counter read back on the event thread, not with a lock.

*/ @EdtTest - public void aStateCarryingAForeignRelayEraIsNotAdmitted() { + public void aFetchStartedBeforeALogoutIsNotDeliveredAfterIt() { Continuity.enable(); final int[] seen = new int[1]; Continuity.addContinuationListener(new ContinuityListener() { @@ -577,20 +609,22 @@ public boolean stateReceived(AppState state) { } }); - // 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() { + final BlockingFetchRelay r = new BlockingFetchRelay(); + r.answer = foreign("device-x", 3); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { public void run() { - try { - Thread.sleep(250); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } + r.awaitInFlight(); } }); + // The user signs out while the fetch is still held. + Continuity.clear(); + r.release(); + pause(250L); + assertEquals(0, seen[0], - "a state from a previous relay session was delivered into this one"); + "a state fetched before the logout was delivered into the session after it"); } /** @@ -746,13 +780,12 @@ public void run() { } /** - * A relay answer fetched before a disable() must not be admitted after a re-enable. - * accountEra moves on clear() and setRelay() but NOT on disable(), so carrying only that - * generation let work started in the previous run restore into the new one -- precisely the - * rejection the lifecycle generation exists to perform. + * A platform continuation carries nothing but the state, and is delivered on its own merits. + * Kept as the positive case beside the rejections above: a guard that refused everything + * would pass those and still break the feature. */ @EdtTest - public void aStateFetchedBeforeADisableIsNotAdmittedAfterReEnable() { + public void aPlatformArrivalIsDelivered() { Continuity.enable(); final int[] seen = new int[1]; Continuity.addContinuationListener(new ContinuityListener() { @@ -762,66 +795,14 @@ public boolean stateReceived(AppState state) { } }); - // What a poll captured before the application switched continuity off and on again. - AppState inFlight = foreign("device-preexisting", 7); - Continuity.disable(); - Continuity.enable(); - - // era 0 was this session's account era when the fetch started; the delivery generation - // has moved twice since. - Continuity.deliver(inFlight, 0L, 0L); - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - try { - Thread.sleep(250); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - }); - - assertEquals(0, seen[0], - "a state fetched before the disable was restored into the re-enabled run"); - } - - /** - * A platform continuation carries no generation of its own -- the OS has no notion of our - * eras -- so both era predicates are skipped for it. What it can still be held to is that - * nothing changed while delivery was being decided, and without that a clear() landing - * between the two locked checks admitted the arrival and stamped it with the NEW generation. - */ - @EdtTest - public void aPlatformArrivalIsStillRejectedWhenTheGenerationMoves() { - Continuity.enable(); - final int[] seen = new int[1]; - Continuity.addContinuationListener(new ContinuityListener() { - public boolean stateReceived(AppState state) { - seen[0]++; - return true; - } - }); - - // A state whose maxAge check will run while we move the generation underneath it: the - // isTooOld() and getDeviceId() calls in deliver() both take and release the lock. AppState arrival = foreign("device-platform", 11); Continuity.disable(); Continuity.enable(); - // NO_ERA on both, which is exactly how a platform continuation is delivered. Continuity.deliver(arrival); - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - try { - Thread.sleep(250); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - }); + pause(250L); - // It IS admitted here -- nothing moved during the decision -- which is the correct - // behaviour and what makes the guard a guard rather than a blanket refusal. - assertEquals(1, seen[0], "a platform arrival with a settled generation must be delivered"); + assertEquals(1, seen[0], "a platform arrival must be delivered"); } /** @@ -980,7 +961,11 @@ public void aReconnectDuringAFailedPublishIsRetried() { Continuity.setRelay(r); Continuity.checkpoint(); - r.awaitInPublish(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitInPublish(); + } + }); // The application reconnects while the first attempt is still on the wire. Continuity.pollRelay(); // Now let that attempt fail; the next one is allowed to succeed. @@ -989,12 +974,7 @@ public void aReconnectDuringAFailedPublishIsRetried() { long deadline = System.currentTimeMillis() + 3000L; while (r.delivered() == 0 && System.currentTimeMillis() < deadline) { - try { - Thread.sleep(25); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - break; - } + pause(25L); } assertTrue(r.delivered() > 0, @@ -1057,10 +1037,14 @@ void awaitInPublish() { */ @EdtTest public void aCoalescedPollUsesTheReplacementRelay() { - BlockingFetchRelay old = new BlockingFetchRelay(); + final BlockingFetchRelay old = new BlockingFetchRelay(); Continuity.enable(); Continuity.setRelay(old); - old.awaitInFlight(); + awaitOffEdt(new Runnable() { + public void run() { + old.awaitInFlight(); + } + }); // Queued while the old relay's fetch is still held, which is what makes it coalesce. BlockingFetchRelay replacement = new BlockingFetchRelay(); @@ -1070,12 +1054,7 @@ public void aCoalescedPollUsesTheReplacementRelay() { long deadline = System.currentTimeMillis() + 3000L; while (replacement.fetches() == 0 && System.currentTimeMillis() < deadline) { - try { - Thread.sleep(20); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - break; - } + pause(20L); } assertEquals(1, old.fetches(), @@ -1101,9 +1080,17 @@ public void overlappingPollsNeverRunTwoFetchesAtOnce() { for (int i = 0; i < 6; i++) { Continuity.pollRelay(); } - r.awaitInFlight(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitInFlight(); + } + }); r.release(); - r.awaitQuiet(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitQuiet(); + } + }); assertEquals(1, r.maxConcurrent(), "two relay fetches overlapped, so an older response can land after a newer one"); @@ -1234,6 +1221,9 @@ int callbackInstalls() { /** Holds every fetch until released, and records how many ran at once. */ static class BlockingFetchRelay implements StateRelay { + /** What fetch() returns once released, or null for "the endpoint has nothing". */ + volatile AppState answer; + private final java.util.concurrent.CountDownLatch gate = new java.util.concurrent.CountDownLatch(1); private final java.util.concurrent.atomic.AtomicInteger inFlight = @@ -1261,7 +1251,7 @@ public AppState fetch() { Thread.currentThread().interrupt(); } inFlight.decrementAndGet(); - return null; + return answer; } void release() { @@ -1377,26 +1367,6 @@ public void aDeliveryQueuedBeforeDisableDoesNotDispatch() { 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 @@ -1415,8 +1385,12 @@ public void aStateStillQueuedAtLogoutIsNeverSent() { Continuity.setRelay(r); Continuity.checkpoint(); - r.awaitEntered(); - long inFlight = Continuity.getRestorableState().getSequence(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + final long inFlight = Continuity.getRestorableState().getSequence(); // Queued behind the request the worker is holding. provider.saved.put("n", Integer.valueOf(2)); @@ -1428,8 +1402,12 @@ public void aStateStillQueuedAtLogoutIsNeverSent() { 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(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitSent(inFlight); + r.settle(); + } + }); assertFalse(r.sent.contains(Long.valueOf(queued)), "a state queued before logout was published after it: " + r.sent); From 2598337d110e70b9d2286d93a9d5d1b7b4a514bd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:18:23 +0300 Subject: [PATCH 031/140] Continuity: stop publishing after disable(), and mark a route-less restore Two bugs, both in single-threaded logic rather than in the threading model. disable() left the relay session alone. clear() has always ended it; disable() is a weaker promise about the same machinery and only did half the job, so a checkpoint whose publish was deferred behind an active fetch stayed in the slot, and pollFinished() started a publisher for it when that fetch landed -- POSTing a state after disable() had returned, which is the opposite of "checkpoints stop". disable() now ends the session, and startPublisher() requires `enabled` as the general invariant: it is the one funnel every publication passes through, including the ones a worker starts after the application turned the framework off. A payload-only restore was never acknowledged. The route-less branch returns early, which put it before noteActedOn() -- so the one case that path exists for was the one case that never got marked. The relay's unchanged document was accepted again after every restart, and with automatic restore off the parked slot was re-applied on every call, because it was released only when a form had appeared. The state is acknowledged once now, straight after the provider has its payload, and the parked slot is released on application rather than on a form appearing. The comment that used to sit at the bottom of restore() described this exact bug and asserted it was fixed, while the early return above it made the code unreachable. It has been rewritten to say where the acknowledgement happens. Three tests, each proven by reverting its own fix: reverting all three produces exactly three failures, one per fix. Continuity suite 92/92 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright/control-character/cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 37 ++++--- .../continuity/LocalContinuityTest.java | 97 +++++++++++++++++++ 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 3fb955a08a5..e1b77a3323e 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -243,6 +243,13 @@ public static void disable() { enabled = false; dirty = false; parked = null; + // The relay session ends too. A checkpoint whose publish was deferred behind a fetch is + // still sitting in the slot, and pollFinished() starts a publisher when that fetch lands + // -- so without this a state queued before disable() went out on the wire after it had + // returned, which is exactly what "checkpoints stop" is supposed to mean. clear() has + // always done this; disable() is a weaker promise about the same machinery, not a + // different one. + endRelaySession(); clearContinuation(); } @@ -636,7 +643,10 @@ public static boolean restore() { // 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 && isSameState(parked, state)) { + // Released because the state was APPLIED, not because a form appeared. Gating this on + // `shown` kept a payload-only arrival parked for ever: restore(state) hands the payload + // to the provider and returns false, so every later call re-applied the same state. + if (isSameState(parked, state)) { parked = null; } return shown; @@ -698,6 +708,16 @@ public static boolean restore(final AppState state) { Log.e(t); } } + // Acknowledged HERE, before the branch below, because the state has now been APPLIED -- + // which is not the same question as whether a form appeared. + // + // The comment that used to sit at the bottom of this method said exactly that and was + // wrong about where it happened: the route-less return below skipped it, so the one case + // it named -- a payload-only continuation, what an app that does not use @Route gets -- + // was the case that never got marked. The relay offered the unchanged document again + // after every restart, and with automatic restore off the no-argument wrapper re-applied + // it on every call. + noteActedOn(state); List routes = state.getRoutes(); if (routes.isEmpty()) { // Payload-only restoration, which is what an app that does not use @Route gets. The @@ -733,15 +753,6 @@ public static boolean restore(final AppState state) { // 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; } @@ -1013,7 +1024,11 @@ private static void publishToRelay(AppState state) { /// one: a state retained after a failed send is sent by whatever finishes next -- a poll, or /// the following checkpoint -- rather than sitting in the slot forever. private static void startPublisher() { - if (!Display.isInitialized() || relay == null) { + if (!Display.isInitialized() || relay == null || !enabled) { + // `enabled` as the general invariant, beside the specific drop in disable(). Nothing + // may reach the relay while the framework is off, and this is the one funnel every + // publication passes through -- including the ones started by a worker completing + // after the application turned it off. return; } if (publishing) { 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 index a7e0c9e4ee7..653e6ac2ce9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1232,8 +1232,16 @@ static class BlockingFetchRelay implements StateRelay { new java.util.concurrent.atomic.AtomicInteger(); private final java.util.concurrent.atomic.AtomicInteger count = new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicInteger posts = + new java.util.concurrent.atomic.AtomicInteger(); public void publish(AppState state) { + posts.incrementAndGet(); + } + + /** How many states actually reached the endpoint. */ + int published() { + return posts.get(); } public AppState fetch() { @@ -1367,6 +1375,95 @@ public void aDeliveryQueuedBeforeDisableDoesNotDispatch() { assertEquals(0, listener.calls, "a delivery from before disable() still dispatched"); } + /** + * disable() documents that checkpoints stop. A publish deferred behind a relay fetch used to + * go out anyway: the state sat in the pending slot, disable() left it there, and the fetch + * landing afterwards started a publisher that POSTed it -- after disable() had returned. + * + *

The EDT model is what makes this the only shape worth testing. Nothing can interleave + * within a turn, so the sole way work outlives the decision is a relay round trip, and this + * is that path.

+ */ + @EdtTest + public void aPublishDeferredBehindAFetchIsNotSentAfterDisable() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final BlockingFetchRelay r = new BlockingFetchRelay(); + Continuity.setRelay(r); + // setRelay polls, so the fetch is in flight and any checkpoint defers behind it. + awaitOffEdt(new Runnable() { + public void run() { + r.awaitInFlight(); + } + }); + + Continuity.checkpoint(); + Continuity.disable(); + r.release(); + pause(500L); + + assertEquals(0, r.published(), + "a state queued before disable() was published after it"); + } + + /** + * A payload-only continuation -- what an app that does not use @Route gets -- is APPLIED even + * though no form appears, so it must be marked acted-on. The route-less return skipped the + * acknowledgement, so the relay's unchanged document was accepted again after a restart and + * the listener repeated its side effects. + */ + @EdtTest + public void aPayloadOnlyRestoreIsStillMarkedActedOn() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState payloadOnly = fromElsewhere("payload only", 5L); + payloadOnly.setRoutes(new ArrayList()); + + assertFalse(Continuity.restore(payloadOnly), + "a route-less state shows no form, so restore must report false"); + + // Re-delivered exactly as a relay would after a restart. The mark has to reject it. + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + Continuity.deliver(payloadOnly); + flushSerialCalls(); + + assertEquals(0, seen[0], + "a payload-only state that was already applied was delivered a second time"); + } + + /** + * And the parked slot is released on application, not on a form appearing. Gating it on the + * return value kept a payload-only arrival parked for ever, so every restore() re-applied it. + */ + @EdtTest + public void aPayloadOnlyParkedStateIsNotOfferedTwice() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + AppState payloadOnly = fromElsewhere("parked payload", 9L); + payloadOnly.setRoutes(new ArrayList()); + Continuity.deliver(payloadOnly); + flushSerialCalls(); + + assertFalse(Continuity.restore(), "a route-less state shows no form"); + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && isSame(left, payloadOnly), + "the parked state was applied and must not still be offered"); + } + + private static boolean isSame(AppState a, AppState b) { + return a.getDeviceId().equals(b.getDeviceId()) && a.getSequence() == b.getSequence(); + } + /** * 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 From b39549c562cba0ee95ce98a14dbda5f5b14a6db5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:44:17 +0300 Subject: [PATCH 032/140] Continuity: store an applied state before acknowledging it, and observe the synced store regardless of the probe Both of these are consequences of earlier fixes in this branch rather than of the original design. A payload-only continuation was acknowledged and never written. Moving noteActedOn() above the route-less return was right -- that branch is the one case the acknowledgement exists for -- but persist() stayed below it, gated on a form having appeared. The state was therefore marked durably, so the relay refuses its copy for good, while nothing had been stored: a process death between the two lost it outright. An app that does not use @Route has nothing else that checkpoints, which is exactly the app this shape of state belongs to. The order is now persist, then acknowledge, for both shapes. That direction is the safe one: its worst case is one redundant re-delivery of a state that is already applied, which restoring again handles, against losing the state. The iOS synced store installed its external-change observer only when the initial synchronize() probe succeeded. Latching success rather than failure -- so an offline launch retries on the next call -- fixed the store but not the observer: an application that only registers a SyncedStoreListener makes exactly ONE store call, from addChangeListener, so there is no next call and reconnecting produced no callback for the life of the process. Registering for a notification is local and needs neither connectivity nor a successful probe, so the observer now goes on independently, latched separately so two callers cannot both register and deliver every change twice. The exception handler no longer nulls `store`. It moved out of the store == nil guard with the observer, so it now covers calls that had already resolved -- and this port is MRR, so nulling a retained store leaks it and discards a working store because a later synchronize threw. aPayloadOnlyParkedStateIsNotOfferedTwice asserted the applied state was no longer offered. Persisting it makes that false and correctly so: an applied state IS the local checkpoint, which is what the next cold start should find. The test now deletes the stored copy first, so only the parked slot can answer, which is the invariant it was really protecting and is sharper than what it replaced -- the old form passed whenever the two states merely compared unequal. Continuity suite 93/93 twice; each fix proven by reverting it alone. SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright/control-character/ cast gates clean, native block clean under clang -Wall. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 16 +++++--- Ports/iOSPort/nativeSources/IOSNative.m | 24 +++++++++--- .../continuity/LocalContinuityTest.java | 38 ++++++++++++++++--- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e1b77a3323e..c64cc051795 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -717,6 +717,16 @@ public static boolean restore(final AppState state) { // was the case that never got marked. The relay offered the unchanged document again // after every restart, and with automatic restore off the no-argument wrapper re-applied // it on every call. + // Written to storage BEFORE it is acknowledged, and for both shapes of state. + // + // The order matters and this is the safe one. noteActedOn() is durable: once it has run, + // the relay's copy is refused for good. Acknowledging first and dying before the write + // lost the state entirely -- the payload was applied in memory, never stored, and never + // offered again -- and a payload-only continuation is exactly the shape most likely to + // hit it, because an app that does not use @Route has nothing else that checkpoints. The + // reverse order costs at worst one re-delivery of a state that is already applied, which + // restoring again handles. + persist(state); noteActedOn(state); List routes = state.getRoutes(); if (routes.isEmpty()) { @@ -747,12 +757,6 @@ public static boolean restore(final AppState state) { } finally { 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); - } return shown; } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 869dfaa15ca..9661b9f16d0 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20661,11 +20661,19 @@ static id cn1ContinuitySanitize(id value) { // 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]; + // The observer goes on independently of the probe, and this is the half that used to be + // missing. Registering for a notification is local: it needs no connectivity and no + // successful synchronize, and the store object is the same singleton either way. Tying it + // to the probe meant an offline launch installed no observer at all -- and an application + // that only registers a SyncedStoreListener makes exactly ONE store call, from + // addChangeListener, so nothing ever asked again. Reconnecting produced no callback for + // the life of the process, which is the whole feature for that app. + // + // Latched separately from `store` for the reason the store is latched at all: two callers + // arriving together must not both register, or every remote change is delivered twice. + if (s != nil && cn1ContinuityStoreObserver == nil) { cn1ContinuityStoreObserver = [[[NSNotificationCenter defaultCenter] addObserverForName:NSUbiquitousKeyValueStoreDidChangeExternallyNotification object:s @@ -20675,9 +20683,15 @@ static id cn1ContinuitySanitize(id value) { CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); }] retain]; } + if (store == nil && s != nil && [s synchronize]) { + store = [s retain]; + } } @catch (NSException *e) { - store = nil; - } + // Deliberately NOT "store = nil". The handler now covers calls that already resolved -- + // it moved out of the store == nil guard when the observer stopped depending on the probe + // -- and this port is MRR, so nulling a retained store would both leak it and lose a + // working store because a later synchronize threw. A failed attempt simply leaves the + // state it found: still nil, so the next call tries again. } pthread_mutex_unlock(&cn1ContinuityStoreLock); return store; 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 index 653e6ac2ce9..290474b335b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1439,6 +1439,31 @@ public boolean stateReceived(AppState state) { "a payload-only state that was already applied was delivered a second time"); } + /** + * An applied state has to reach local storage, and a payload-only one is the case that proves + * it. noteActedOn() is durable -- once it runs the relay's copy is refused for good -- so a + * state that was acknowledged and never written is lost outright if the process dies before + * anything else checkpoints. An app that does not use @Route has nothing else that + * checkpoints, which is exactly the app this shape of state belongs to. + */ + @EdtTest + public void aPayloadOnlyRestoreIsWrittenToStorage() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState payloadOnly = fromElsewhere("stored payload", 12L); + payloadOnly.setRoutes(new ArrayList()); + + assertFalse(Continuity.restore(payloadOnly), "a route-less state shows no form"); + + // What the next cold start would find: nothing is parked, so this is storage answering. + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, + "the applied payload-only state was acknowledged but never written to storage"); + assertEquals("stored payload", stored.getPayload().get("note"), + "storage holds a different state than the one that was applied"); + } + /** * And the parked slot is released on application, not on a form appearing. Gating it on the * return value kept a payload-only arrival parked for ever, so every restore() re-applied it. @@ -1455,13 +1480,14 @@ public void aPayloadOnlyParkedStateIsNotOfferedTwice() { flushSerialCalls(); assertFalse(Continuity.restore(), "a route-less state shows no form"); - AppState left = Continuity.getRestorableState(); - assertFalse(left != null && isSame(left, payloadOnly), - "the parked state was applied and must not still be offered"); - } - private static boolean isSame(AppState a, AppState b) { - return a.getDeviceId().equals(b.getDeviceId()) && a.getSequence() == b.getSequence(); + // Storage legitimately holds it now -- an applied state IS the local checkpoint, which is + // what the next cold start should come back to -- so asking getRestorableState() alone + // cannot tell the parked slot from the stored copy. Remove the stored copy and ask again: + // whatever answers now can only be the parked slot, and it has to be empty. + Storage.getInstance().deleteStorageFile(Continuity.STORAGE_KEY); + AppState left = Continuity.getRestorableState(); + assertNull(left, "the parked state was applied and must not still be waiting"); } /** From b8bc6d523295c513215842f3553ad7a68039bf27 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:06:57 +0300 Subject: [PATCH 033/140] Continuity: only do durable bookkeeping after a confirmed durable write persist() discarded what Storage.writeObject returns, and everything around it assumed the write had happened. checkpoint() had already cleared `dirty`, so a write that failed on a full disk was never retried and the app came back to the last one that had succeeded; restore() went on to call noteActedOn(), which is durable and stops the relay ever offering that state again -- losing it in both directions at once, with nothing anywhere saying so. This is the third and last point on the same chain. Persisting before acknowledging closed the crash window between them; it did nothing about the write simply failing, because the ordering was the part that got fixed rather than the invariant behind it. The invariant is that durable bookkeeping follows a CONFIRMED durable write, so persist() now returns whether the state got there, checkpoint() leaves it owed when it did not, and restore() acknowledges only on success. An unacknowledged state is offered again by the relay, which is recoverable; the alternative is not. The continuation and the relay copy still go out either way -- reaching another device is worth having even when local storage would not take it. Two documentation defects with the same root, both about what returning false from a ContinuityListener means: The "Stay here" branch neither restored nor acknowledged, under a comment claiming the state was consumed either way. acknowledge()'s own javadoc says the opposite -- false also means "hold it, I will ask again later" -- so a decline was suppressed for that run only and the relay's unchanged document re-prompted after every relaunch. Fixed in the guide snippet and in the sample, which repeated it. The sample captured its draft from TextArea.addActionListener, which fires when editing ENDS and whose javadoc warns it "might never fire an action event if it is edited in place and the user never leaves the text field". A draft still being typed was therefore never captured, so the sample lost precisely the half-finished draft it exists to preserve. It uses a DataChangedListener now. Checkpointing per keystroke is the intent rather than an oversight: the feature is write-through by design, and an app whose whole state is one text field has nothing else that would ever checkpoint. Suite 95/95 twice, both new tests proven by reverting the fix. Sample and snippet compiled against core -- neither is in the core build. Guide snippet gate 754 blocks, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright/control-character/cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 35 ++++++++-- .../ContinuitySample/ContinuitySample.java | 22 ++++++- .../continuity/ContinuitySnippets.java | 8 ++- .../continuity/LocalContinuityTest.java | 65 +++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index c64cc051795..d5704171283 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -503,7 +503,14 @@ public static void checkpoint() { if (state == null) { return; } - persist(state); + if (!persist(state)) { + // Still owed. `dirty` was cleared on the way in, and leaving it clear told the next + // suspend there was nothing to write -- so a checkpoint that failed on a full disk + // was never retried, and the app came back to whatever the last successful write + // held. The other channels still run: a continuation and a relay copy that reached + // the user's other devices are worth having even when this one could not be stored. + dirty = true; + } publishContinuation(state); publishToRelay(state); } @@ -726,8 +733,14 @@ public static boolean restore(final AppState state) { // hit it, because an app that does not use @Route has nothing else that checkpoints. The // reverse order costs at worst one re-delivery of a state that is already applied, which // restoring again handles. - persist(state); - noteActedOn(state); + if (persist(state)) { + // Acknowledged only when the write succeeded, for the reason it happens before the + // acknowledgement at all: noteActedOn() is durable and makes the relay refuse this + // state for good. Doing that on top of a failed write is the same loss the ordering + // exists to prevent, one step further along -- nothing stored here, and nothing left + // to fetch. Left unacknowledged, the relay offers it again, which is recoverable. + noteActedOn(state); + } List routes = state.getRoutes(); if (routes.isEmpty()) { // Payload-only restoration, which is what an app that does not use @Route gets. The @@ -915,11 +928,23 @@ private static List currentRoutes() { return paths; } - private static void persist(AppState state) { + /// Writes the checkpoint, and says whether it got there. + /// + /// The answer is used, not logged. Storage.writeObject returns false on a failed write -- a + /// full disk is the ordinary cause -- and every piece of bookkeeping around this call assumes + /// the state is now durable: checkpoint() clears `dirty`, and restore() marks the sender's + /// sequence so the relay stops offering its copy. Doing either after a failed write is how a + /// state is lost in both directions at once, with nothing anywhere saying so. + /// + /// #### Returns + /// + /// true when the state is in storage + private static boolean persist(AppState state) { try { - Storage.getInstance().writeObject(STORAGE_KEY, state); + return Storage.getInstance().writeObject(STORAGE_KEY, state); } catch (Throwable t) { Log.e(t); + return false; } } diff --git a/Samples/samples/ContinuitySample/ContinuitySample.java b/Samples/samples/ContinuitySample/ContinuitySample.java index fa674c1f4a3..294519c9d8a 100644 --- a/Samples/samples/ContinuitySample/ContinuitySample.java +++ b/Samples/samples/ContinuitySample/ContinuitySample.java @@ -37,6 +37,7 @@ import com.codename1.ui.Toolbar; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.DataChangedListener; import com.codename1.ui.layouts.BoxLayout; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.Resources; @@ -110,6 +111,12 @@ public boolean stateReceived(final AppState state) { if (Dialog.show("Continue?", "Pick up \"" + label + "\"?", "Continue", "Stay")) { Continuity.restore(state); showDraftForm(); + } else { + // Declining is recorded, not just acted on. Returning false alone suppresses + // the state for this run only -- false also means "hold it, I will ask again" + // -- so without this the relay's unchanged document re-prompts after every + // relaunch. + Continuity.acknowledge(state); } // Consumed either way: the decision has been made here, so no other listener is // asked and nothing is restored behind this one's back. @@ -150,8 +157,19 @@ 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) { + // A DATA CHANGE listener, not an action listener. TextArea.addActionListener fires when + // editing ENDS -- its own javadoc warns it "might never fire an action event if it is + // edited in place and the user never leaves the text field" -- so a draft that is still + // being typed was never captured, and the sample lost precisely the half-finished draft + // it exists to preserve: killed mid-sentence, or picked up on another device, the state + // held whatever was there the last time focus left the field. + // + // Checkpointing per keystroke is the point rather than an oversight. This feature is + // write-through by design -- there is no reliable "save on exit" callback to hang it on, + // which the chapter says outright -- and an app whose whole state is one text field has + // nothing else that would ever checkpoint. + field.addDataChangeListener(new DataChangedListener() { + public void dataChanged(int type, int index) { capture(); } }); 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 index 0065ef289fe..48b71a17770 100644 --- 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 @@ -97,8 +97,14 @@ public boolean stateReceived(AppState state) { if (Dialog.show("Continue?", "Pick up \"" + state.getTitle() + "\" from your other device?", "Continue", "Stay here")) { Continuity.restore(held); + } else { + // Declining is a decision, and it has to be recorded. Returning false alone + // only suppresses the state for THIS run -- false also means "keep it, I will + // prompt again later" -- so without this the relay's unchanged document asks + // the same question after every relaunch. + Continuity.acknowledge(state); } - // Consumed either way: the decision has been made here. + // Consumed either way: the decision has been made here, and recorded either way. return false; } }); 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 index 290474b335b..0e791eb072a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1439,6 +1439,71 @@ public boolean stateReceived(AppState state) { "a payload-only state that was already applied was delivered a second time"); } + /** + * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving + * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full + * disk was never retried and the app came back to the last write that had succeeded. + */ + @EdtTest + public void aFailedCheckpointWriteLeavesTheStateOwed() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingStorage()); + try { + Continuity.routeStackChanged(); + Continuity.checkpoint(); + assertTrue(Continuity.isCheckpointPending(), + "a checkpoint whose write failed was reported as saved"); + } finally { + Storage.setStorageInstance(original); + } + } + + /** + * And a restore whose write failed must not acknowledge the state. noteActedOn() is durable + * and stops the relay ever offering it again, so doing it on top of a failed write loses the + * state in both directions at once -- nothing stored here, nothing left to fetch. + */ + @EdtTest + public void aFailedRestoreWriteDoesNotAcknowledgeTheState() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState arrival = fromElsewhere("unstorable", 21L); + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingStorage()); + try { + Continuity.restore(arrival); + } finally { + Storage.setStorageInstance(original); + } + + // The relay would offer it again. It has to be accepted, not refused as already handled. + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + Continuity.deliver(arrival); + flushSerialCalls(); + + assertEquals(1, seen[0], + "a state whose write failed was marked handled, so it can never be recovered"); + } + + /** Storage whose writes always fail, which is what a full disk looks like. */ + static class RefusingStorage extends Storage { + @Override + public boolean writeObject(String name, Object o) { + return false; + } + } + /** * An applied state has to reach local storage, and a payload-only one is the case that proves * it. noteActedOn() is durable -- once it runs the relay's copy is refused for good -- so a From b19fc67a7cd5618520a6b1b9f729252bd2f98577 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:35:01 +0300 Subject: [PATCH 034/140] Continuity: order the relay pair in both directions, and keep the checkpoint until something is applied startPublisher() already defers a POST behind an active fetch, and says why: the relay holds one document per user, so a publish that lands first replaces the other device's state, and the GET then reads back this device's own echo, which admit() drops as an echo should be dropped. The remote update is gone from the relay and was never seen. pollRelay() had no matching guard, so the same race was open from the other side -- a poll started during an in-flight publish loses exactly that. Read before write now holds in both directions, and publishFinished() drains a deferred poll BEFORE any queued publication, so the ordering survives the handoff as well as the entry. restore() wrote and acknowledged the incoming state before it knew whether any of it could be applied. A route-only state naming routes this build no longer registers applies nothing at all: restoreStack() returns false, the user stays where they were, and the stored checkpoint -- their own restorable position -- had already been replaced by something unusable, with the acknowledgement stopping the relay from ever offering it again. Nothing is written or acknowledged now until some part of the state has actually reached the application, and the applied/persist/acknowledge rule lives in one commit() helper rather than being re-derived at each call site. That rule has now cost four separate findings; putting it in one place is what stops a fifth. Both tests were unsound when first written and are fixed here, not just added. aPollDoesNotOverlapAPublishInFlight read the fetch count on the event thread one line after pollRelay(), while the fetch runs on a spawned worker -- so the count was stale whether or not a fetch had wrongly started, and it passed with the guard deleted. It waits before declaring the absence now, paired with the deferred poll landing as the positive signal. Both are proven by reverting the fix they cover: 1 fetch against 2, and the foreign sequence replacing the local checkpoint. Suite 97/97 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright/control-character/cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 93 +++++++++------- .../continuity/LocalContinuityTest.java | 100 ++++++++++++++++++ 2 files changed, 153 insertions(+), 40 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index d5704171283..9bc3126af3d 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -698,49 +698,25 @@ public static boolean restore(final AppState state) { if (state == null) { return false; } - // NOT serialized against clear(), and nothing here needs to be. A review asked twice for - // a lock around the provider and navigation work below, on the reading that a worker can - // call clear() midway through and have this recreate the checkpoint it just deleted. - // There is no such worker: clear() is event-thread API like every other method on this - // class, so it runs either entirely before this or entirely after it. The lock that - // question asks for is the one that made this class need a lock-ordering rule to be - // safe from itself. See the threading note on the class. + // Whether any part of this state actually reached the application. Nothing is written + // or acknowledged until something has: a route-only state naming routes this build no + // longer registers applies nothing at all, and replacing the stored checkpoint with it + // destroyed the user's own restorable position -- while acknowledging it stopped the + // relay offering it again, so the next launch found only the unusable state where a good + // checkpoint had been. Left alone, the old checkpoint still restores and the relay may + // offer this one to a build that understands it. + boolean applied = false; 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()); + // An empty payload is not an application. It is what a route-only state carries, + // and counting it would make the question above answer yes for every state. + applied = !state.getPayload().isEmpty(); } catch (Throwable t) { Log.e(t); } } - // Acknowledged HERE, before the branch below, because the state has now been APPLIED -- - // which is not the same question as whether a form appeared. - // - // The comment that used to sit at the bottom of this method said exactly that and was - // wrong about where it happened: the route-less return below skipped it, so the one case - // it named -- a payload-only continuation, what an app that does not use @Route gets -- - // was the case that never got marked. The relay offered the unchanged document again - // after every restart, and with automatic restore off the no-argument wrapper re-applied - // it on every call. - // Written to storage BEFORE it is acknowledged, and for both shapes of state. - // - // The order matters and this is the safe one. noteActedOn() is durable: once it has run, - // the relay's copy is refused for good. Acknowledging first and dying before the write - // lost the state entirely -- the payload was applied in memory, never stored, and never - // offered again -- and a payload-only continuation is exactly the shape most likely to - // hit it, because an app that does not use @Route has nothing else that checkpoints. The - // reverse order costs at worst one re-delivery of a state that is already applied, which - // restoring again handles. - if (persist(state)) { - // Acknowledged only when the write succeeded, for the reason it happens before the - // acknowledgement at all: noteActedOn() is durable and makes the relay refuse this - // state for good. Doing that on top of a failed write is the same loss the ordering - // exists to prevent, one step further along -- nothing stored here, and nothing left - // to fetch. Left unacknowledged, the relay offers it again, which is recoverable. - noteActedOn(state); - } List routes = state.getRoutes(); if (routes.isEmpty()) { // Payload-only restoration, which is what an app that does not use @Route gets. The @@ -752,6 +728,9 @@ public static boolean restore(final AppState state) { // 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. + if (applied) { + commit(state); + } return false; } // Applying a state is not the user navigating, and the difference is not cosmetic. The @@ -770,6 +749,9 @@ public static boolean restore(final AppState state) { } finally { applyingRestore = false; } + if (shown || applied) { + commit(state); + } return shown; } @@ -782,6 +764,16 @@ public static void pollRelay() { if (relay == null || !enabled || !Display.isInitialized()) { return; } + if (publishing) { + // Deferred behind the POST, which is the other half of the rule startPublisher() + // already follows. The relay holds ONE document per user, so a publish in flight can + // replace the other device's state before a GET started now has read it -- and that + // GET then returns this device's own echo, which admit() drops as an echo should be + // dropped. The remote update is gone from the relay and was never seen. Read before + // write, in BOTH directions, is what makes that impossible. + pollAgain = true; + return; + } if (polling) { // One fetch at a time. Two overlapping GETs can return DIFFERENT documents -- a relay // holds one per user and the other device may replace it between them -- and nothing @@ -928,6 +920,18 @@ private static List currentRoutes() { return paths; } + /// Makes an applied state the local checkpoint, and records that it was acted on. + /// + /// The two belong together and in this order. noteActedOn() is durable and stops the relay + /// ever offering this state again, so it may only follow a write that actually succeeded -- + /// otherwise the state is lost in both directions at once, nothing stored here and nothing + /// left to fetch. An unacknowledged state is offered again, which is recoverable. + private static void commit(AppState state) { + if (persist(state)) { + noteActedOn(state); + } + } + /// Writes the checkpoint, and says whether it got there. /// /// The answer is used, not logged. Storage.writeObject returns false on a failed write -- a @@ -1120,11 +1124,20 @@ private static void publishFinished(AppState sent, int session, boolean ok) { // network went away never reached the other device at all. Put back only when nothing // newer is queued, since a newer state supersedes it entirely. pendingPublish = sent; - if (!publishRequested) { - // Stood down rather than retried: one attempt per change, not a spin against an - // endpoint that is down. The next checkpoint or poll starts the next one. - return; - } + } + if (pollAgain) { + // A poll asked for while this was on the wire, and it goes FIRST: read before write + // is the ordering a single-document relay needs, and pollFinished() starts a + // publisher for whatever is still queued when it lands. + pollAgain = false; + publishRequested = false; + startPoll(); + return; + } + if (!ok && !publishRequested) { + // Stood down rather than retried: one attempt per change, not a spin against an + // endpoint that is down. The next checkpoint or poll starts the next one. + return; } // Somebody asked for a publisher while this attempt was in flight -- an application // calling pollRelay() on reconnect is the ordinary case. Consumed rather than looped on, 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 index 0e791eb072a..d2f579689c4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1439,6 +1439,89 @@ public boolean stateReceived(AppState state) { "a payload-only state that was already applied was delivered a second time"); } + /** + * A relay holds ONE document per user, so a GET started while a POST is on the wire can lose + * the other device's state outright: the POST replaces it, and the GET then reads back this + * device's own echo, which admit() correctly drops. startPublisher() already defers behind an + * active fetch for this reason; the poll had no matching guard, so the rule held in one + * direction only. + */ + @EdtTest + public void aPollDoesNotOverlapAPublishInFlight() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + + // A checkpoint puts a POST on the wire and the relay holds it there. + Continuity.checkpoint(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + + // A DELTA, not an absolute count: setRelay() polls on installation, so a fetch has + // already been and gone by the time the publish is on the wire. What must not happen is + // another one starting now. + final int before = r.fetches(); + + // The application reconnects and polls while that POST is still in flight. + Continuity.pollRelay(); + // Given time to happen before it is declared absent. startPoll() spawns a worker, so + // reading the count on the very next line races it: the assertion passed whether or not a + // fetch had been wrongly started, which is no assertion at all. The positive signal below + // is what makes this absence mean something. + pause(400L); + assertEquals(before, r.fetches(), + "a fetch was started while a publish was on the wire, so the POST can overwrite " + + "the other device's state before the GET reads it"); + + // Released, the deferred poll runs. + r.release(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitFetched(before + 1); + } + }); + assertTrue(r.fetches() > before, + "the deferred poll was dropped rather than run afterwards"); + } + + /** + * A route-only state whose routes this build no longer registers applies nothing. Writing it + * over the stored checkpoint destroyed the user's own restorable position, and acknowledging + * it stopped the relay offering it again -- so the next launch found only the unusable state. + */ + @EdtTest + public void anUnrestorableStateDoesNotReplaceTheCheckpoint() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + + // The user's own checkpoint, which must survive. + Continuity.routeStackChanged(); + Continuity.checkpoint(); + long mine = Continuity.getRestorableState().getSequence(); + + // A foreign state naming a route this build does not have, and carrying no payload. + AppState foreignState = new AppState() + .setDeviceId("some-other-device") + .setSequence(77L) + .setTimestamp(System.currentTimeMillis()); + List unknown = new ArrayList(); + unknown.add("/a-route-this-build-does-not-register"); + foreignState.setRoutes(unknown); + + assertFalse(Continuity.restore(foreignState), "an unknown route cannot show a form"); + + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, "the local checkpoint was destroyed by a state that applied nothing"); + assertEquals(mine, stored.getSequence(), + "the unusable foreign state replaced the user's own checkpoint"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full @@ -1622,10 +1705,27 @@ public void publish(AppState state) { sent.add(Long.valueOf(state.getSequence())); } + private final java.util.concurrent.atomic.AtomicInteger fetched = + new java.util.concurrent.atomic.AtomicInteger(); + public AppState fetch() { + fetched.incrementAndGet(); return null; } + /** How many GETs have actually reached the endpoint. */ + int fetches() { + return fetched.get(); + } + + /** Waits until at least `count` GETs have run, so a deferred poll can be seen to land. */ + void awaitFetched(int count) { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline && fetched.get() < count) { + sleepBriefly(); + } + } + void awaitEntered() { try { entered.await(3, java.util.concurrent.TimeUnit.SECONDS); From a72662e50abc1898660789d3fab20d8140d7bdaf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:10:08 +0300 Subject: [PATCH 035/140] Continuity: one owner for the durable mark, and a cap where entries go in dispatch() wrote the high-water mark behind commit()'s back. admit() has already put the sequence in the live map, so an unconditional rememberSeen() after an automatic restore persisted the mark for a state whose checkpoint had failed to store -- and after a restart enable() reloads it and refuses the relay's only recoverable copy. That is the loss commit() gates against, reached down a second path that never went through it. The mark now has one owner: restore(), through commit(). The in-memory mark still goes in at admission, which is what dedups within a run; durability is a separate question with a single writer. The high-water map was bounded only in the copy taken on the way to storage, so the live map grew for the life of the process and every acknowledgement copied and rescanned it -- memory and CPU both climbing with a relay that supplies many device ids, which is the exact threat model MAX_SEEN's own comment names. The cap now applies where entries go in, at all three insertion points. Storing and acknowledging are separated, which corrects the previous change rather than extending it. Gating both on `applied` meant a state with a payload and no provider installed was never marked -- nothing to store, so nothing acknowledged -- and the relay re-delivered it after every restart for ever; everyDevicesHighWaterMarkSurvivesARestart caught exactly that. They are different questions. WHETHER TO STORE is `applied`: a state that changed nothing here must not replace the user's checkpoint with something unusable. WHETHER TO ACKNOWLEDGE turns only on a write that was attempted and FAILED, because then the relay holds the only copy. "Nothing to store" is not that case, and refusing to mark it only re-prompts the user on every launch for something this build cannot use. A review asked for both halves to be gated together; that is the half which does not hold, and the checkpoint it was really protecting is protected by `applied`. Both tests are proven by reverting the fix each covers: the mark written despite a failed checkpoint, and the live map at 200 entries against a cap of 64. Suite 99/99, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright/control-character/cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 120 ++++++++++++------ .../continuity/LocalContinuityTest.java | 52 ++++++++ 2 files changed, 133 insertions(+), 39 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 9bc3126af3d..203931944b6 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -222,6 +222,7 @@ public static void enable() { lastSeen.put(e.getKey(), e.getValue()); } } + trimSeen(); enabled = true; ContinuityBridge b = bridgeInternal(); if (b != null) { @@ -728,9 +729,7 @@ public static boolean restore(final AppState state) { // 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. - if (applied) { - commit(state); - } + commit(state, applied); return false; } // Applying a state is not the user navigating, and the difference is not cosmetic. The @@ -749,9 +748,7 @@ public static boolean restore(final AppState state) { } finally { applyingRestore = false; } - if (shown || applied) { - commit(state); - } + commit(state, applied || shown); return shown; } @@ -920,16 +917,31 @@ private static List currentRoutes() { return paths; } - /// Makes an applied state the local checkpoint, and records that it was acted on. - /// - /// The two belong together and in this order. noteActedOn() is durable and stops the relay - /// ever offering this state again, so it may only follow a write that actually succeeded -- - /// otherwise the state is lost in both directions at once, nothing stored here and nothing - /// left to fetch. An unacknowledged state is offered again, which is recoverable. - private static void commit(AppState state) { - if (persist(state)) { - noteActedOn(state); + /// Records that a state has been dealt with, and makes it the local checkpoint when there + /// was something to store. + /// + /// Two separate questions, which an earlier version answered with one flag and got wrong. + /// + /// WHETHER TO STORE is `applied`: a state that changed nothing here -- a route this build no + /// longer registers, with no payload the application could take -- must not replace the + /// user's own checkpoint with something unusable. + /// + /// WHETHER TO ACKNOWLEDGE is not the same question. The mark is durable and stops the relay + /// ever offering this state again, so the one case that must never mark is a write that was + /// attempted and FAILED: the relay's copy is then the only copy left. Nothing to store is not + /// that case -- there is nothing to recover, the application has had the state offered to its + /// listeners, and refusing to mark it only re-prompts the user on every launch for something + /// this build cannot use. A review asked for both halves to be gated together; this is the + /// half of that finding which does not hold, and the checkpoint it was really protecting is + /// protected by `applied` above. + private static void commit(AppState state, boolean applied) { + if (applied && !persist(state)) { + // Tried to store it and could not. The relay's copy is now the only one that exists, + // so it must go on being offered: acknowledging here loses the state in both + // directions at once. + return; } + noteActedOn(state); } /// Writes the checkpoint, and says whether it got there. @@ -1226,6 +1238,7 @@ private static void admit(final AppState state) { return; } lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); + trimSeen(); Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1274,16 +1287,17 @@ private static void dispatch(AppState state) { } } if (autoRestore) { + // The mark is written by restore(), through commit(), and ONLY when it committed + // something. There was an unconditional rememberSeen() here, which quietly undid that: + // admit() has already put this sequence in the live map, so persisting the map wrote + // the mark for a state whose checkpoint had failed to store, or that applied nothing + // at all. After a restart enable() reloads it and the relay's only recoverable copy is + // refused -- the very loss commit() gates against, reached down a second path that + // never went through it. + // + // The in-memory mark still goes in at admission, which is what dedups within a run. + // Durability is a separate question and has one owner. 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 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 = state; } @@ -1383,6 +1397,7 @@ private static void noteActedOn(AppState state) { Long seen = lastSeen.get(from); if (seen == null || seen.longValue() < state.getSequence()) { lastSeen.put(from, Long.valueOf(state.getSequence())); + trimSeen(); } // 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 @@ -1392,6 +1407,42 @@ private static void noteActedOn(AppState state) { rememberSeen(); } + /// Test seam: the marks as they would be reloaded on the next launch. + static Map readSeenForTest() { + return readSeen(); + } + + /// Test seam: how many devices the live map is holding. + static int seenSizeForTest() { + return lastSeen.size(); + } + + /// Evicts the lowest sequences until the live map is back inside MAX_SEEN. + /// + /// The LIVE map, not a copy taken on the way to storage. A user has a handful of devices, but + /// the ids arrive from a relay and nothing stops one from feeding many, which is the reason + /// there is a cap at all -- so it has to apply where entries are added rather than where they + /// happen to be written out. + /// + /// The lowest sequences go: those are the devices that have been quiet longest, and losing a + /// mark costs one duplicate delivery rather than anything durable. + private static void trimSeen() { + while (lastSeen.size() > MAX_SEEN) { + String lowest = null; + long lowestSeq = Long.MAX_VALUE; + for (Map.Entry e : lastSeen.entrySet()) { + if (e.getValue().longValue() < lowestSeq) { + lowestSeq = e.getValue().longValue(); + lowest = e.getKey(); + } + } + if (lowest == null) { + break; + } + lastSeen.remove(lowest); + } + } + /// Reads the persisted high-water marks. Never null. private static Map readSeen() { Map out = new HashMap(); @@ -1432,21 +1483,12 @@ private static Map readSeen() { /// 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() { - Map 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); - } + // Serialized straight from the live map, which trimSeen() has already bounded. This used + // to copy and trim HERE, which bounded the preference and left the map itself growing for + // the life of the process -- and made every acknowledgement copy the whole thing and scan + // it back down to the cap, so a relay feeding many device ids cost memory and rising CPU + // at once. The cap belongs where entries go IN. + Map copy = lastSeen; StringBuilder sb = new StringBuilder(); for (Map.Entry e : copy.entrySet()) { if (sb.length() > 0) { 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 index d2f579689c4..37a8a022601 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1522,6 +1522,58 @@ public void anUnrestorableStateDoesNotReplaceTheCheckpoint() { "the unusable foreign state replaced the user's own checkpoint"); } + /** + * Automatic restoration must not write the durable mark when the restore itself declined to. + * admit() has already put the sequence in the live map, so persisting the map behind + * commit()'s back marks a state whose checkpoint never stored -- and after a restart enable() + * reloads that mark and refuses the relay's only recoverable copy. + */ + @EdtTest + public void anAutoRestoreWhoseWriteFailedIsNotDurablyMarked() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState arrival = fromElsewhere("unstorable auto", 31L); + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingStorage()); + try { + Continuity.deliver(arrival); + flushSerialCalls(); + } finally { + Storage.setStorageInstance(original); + } + + // What the next launch would load. The mark must not be there. + Map persisted = Continuity.readSeenForTest(); + assertFalse(persisted.containsKey("some-other-device"), + "a state whose checkpoint never stored was durably marked handled, so the relay's " + + "only copy is refused after a restart"); + } + + /** + * The high-water map is bounded where entries go IN, not only where they are written out. + * Trimming the serialization copy alone left the live map growing for the life of the + * process, and made every acknowledgement copy and rescan it -- memory and CPU both climbing + * with a relay that supplies many device ids. + */ + @EdtTest + public void theLiveHighWaterMapIsBounded() { + Continuity.enable(); + + // Comfortably past the cap, all from distinct devices. + for (int i = 0; i < 200; i++) { + AppState s = new AppState() + .setDeviceId("device-" + i) + .setSequence(i + 1) + .setTimestamp(System.currentTimeMillis()); + Continuity.deliver(s); + } + flushSerialCalls(); + + assertTrue(Continuity.seenSizeForTest() <= 64, + "the live map grew past its cap: " + Continuity.seenSizeForTest()); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From ae7fe02c252f2cb7d39d18614384007446199489 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:28:14 +0300 Subject: [PATCH 036/140] Developer guide: bank the code samples the restore chapters put back check-missing-code-blocks.py is a ratchet in both directions: it fails on a new hole, and on a baseline entry for a hole that no longer exists. Restoring the graphics, io, basics and Components samples (#5677 through #5681) filled 53 of them without banking the result, so the gate has been failing on master ever since -- and therefore on every branch, including this one, for a reason none of them introduced. Deletions only: 53 entries removed, none added. The baseline can only get tighter here, so nothing that was excused before is excused now. Verified on origin/master itself before changing anything: the same exit 1 and the same 53 stale entries, so this is not a consequence of the branch or of being behind. After the change the gate reports 210 known holes, none new, none stale. Co-Authored-By: Claude Opus 5 (1M context) --- .../missing-code-blocks-baseline.txt | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/scripts/developer-guide/missing-code-blocks-baseline.txt b/scripts/developer-guide/missing-code-blocks-baseline.txt index 1ab76343203..c0bed1c9798 100644 --- a/scripts/developer-guide/missing-code-blocks-baseline.txt +++ b/scripts/developer-guide/missing-code-blocks-baseline.txt @@ -181,59 +181,6 @@ appendix_goal_generate_graphql.adoc ends the stream: appendix_goal_generate_grpc.adoc Call sites use the static factory: appendix_goal_generate_grpc.adoc The `@GrpcClient` interface looks like: appendix_goal_generate_openapi.adoc Call sites use the static factory: -io.asciidoc A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: -io.asciidoc A simpler implementation could do something like this: -io.asciidoc Above, if you want to select the IDs of all players that are ranked in the top 2, you can use an expression like: -io.asciidoc Above, you globally find a lastname element with a value of ‘Hewitt’, then grab the parent node of lastname which happens to be the player node, then grab the ID attribute from the player node. Or, you could get the same result from the following simpler statement: -io.asciidoc Above, you selected the IDs of all ranked players. Conversely, you can select the non-ranked players like this: -io.asciidoc After you do that once you can write/read contacts from storage if you so want: -io.asciidoc An `Externalizable` object *must* have a *default public constructor* and must implement the following 4 methods: -io.asciidoc And delete an entry using: -io.asciidoc And vice versa: -io.asciidoc Another approach is to use the `setFailSilently(true)` method on the `ConnectionRequest`. This will prevent the `ConnectionRequest` from displaying any errors to the user. It's a powerful strategy if you use the synchronous version of the APIs for example: -io.asciidoc As part of the premium cloud features it's possible to invoke Log.sendLog() to email a log directly to the developer account. Codename One can do that seamlessly based on changes printed into the log or based on exceptions that are uncaught or logged for example: -io.asciidoc Assuming you added a new date field to the object you can do the following. Notice that a `Date` is a `long` value in Java that can be null. For completeness the full class is presented below: -io.asciidoc Binding makes this all seamless. For example: the code above can be written as: -io.asciidoc By default `GZConnectionRequest` doesn't request gzipped data ( unzips it when its received) but its pretty easy to do so add the HTTP header `Accept-Encoding: gzip` for example: -io.asciidoc Codename One provides many tools to simplify the path between networking/IO & GUI. A common task of showing a wait dialog or progress sign while fetching network data can be simplified by using the https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html[InfiniteProgress] class for example: -io.asciidoc Developers need to write the data of the object in the externalize method using the methods in the data output stream and read the data of the object in the internalize method for example: -io.asciidoc For a lot of REST requests this will fail because you need to add an HTTP header indicating that you accept JSON results. You have a special case support for that: -io.asciidoc For endpoints that return a list of DTOs, use `fetchAsMappedList`: -io.asciidoc For example, if you wish to have finer grained control over the submission process for example: for making a `HEAD` request you can do this with code like: -io.asciidoc For example: to block all network errors from showing anything to the user you could do something like this: -io.asciidoc For example: you can do something like this in your `init(Object)` method: -io.asciidoc For starters all the common methods of `Object` can be implemented with almost no code: -io.asciidoc If a document is ordered, you might want to select nodes by their position, for example: -io.asciidoc If you continue the example from above to show persistence to the SQL database you can do something like this: -io.asciidoc If you continue your example from above you can do something like this: -io.asciidoc Implementing the `Externalizable` interface is important when you want to store a proprietary object. In this case you must register the object with the `com.codename1.io.Util` class so the externalization algorithm will be able to recognize it by name by invoking: -graphics.asciidoc (that's: where the numbers appear), and the remaining marks (corresponding with seconds) will be short: -graphics.asciidoc 3. Invert the translation performed in step 1: -graphics.asciidoc A `URLImage` can be created with a mask adapter to apply an effect to an image. This allows you to round downloaded images or apply any sort of masking for example: you can adapt the round mask code above as such: -graphics.asciidoc And you will translate it down slightly so that it overlaps the center. This translation will be performed on the `GeneralPath` object directly rather than through the `Graphics` context: -graphics.asciidoc Center: -graphics.asciidoc For example: a `pointerPressed()` callback method can look like this: -graphics.asciidoc The `animate()` method in the `AnalogClock` class: -graphics.asciidoc The code to instantiate the clock, and start the animation would be something like: -graphics.asciidoc The remaining drawing code is as follows: -graphics.asciidoc The simple use case is pretty trivial: -graphics.asciidoc To do this you can override: -graphics.asciidoc Top Left Corner: -graphics.asciidoc `ConicGradient` - following the same pattern as the `Shape` hierarchy: -graphics.asciidoc can override or augment whatever the default set: -graphics.asciidoc class is the most common option and accepts a list of color stops along a line: -graphics.asciidoc coordinates of the component. You therefore need to get the absolute clock center position to perform the rotation: -graphics.asciidoc for the images. The default is a scale adapter although you might change that to scale fill in the future: -graphics.asciidoc hook. Two ways to install one: -graphics.asciidoc https://www.codenameone.com/javadoc/com/codename1/ui/Graphics.html#isShapeSupported()[`Graphics.isShapeSupported()`] method. For example: -graphics.asciidoc it can be added to a form: -graphics.asciidoc mark at the 12 o'clock position: -graphics.asciidoc method to append curves to the drawing as follows: -graphics.asciidoc methods in the component as follows: -graphics.asciidoc name of icon to `icon_URLImage` then using this in the data: -graphics.asciidoc show this, try to place five of these components on a form inside a https://www.codenameone.com/javadoc/com/codename1/ui/layouts/BorderLayout.html[BorderLayout] and see how it looks: -graphics.asciidoc straight lines rather than curves might look like this: -graphics.asciidoc to draw each individual tick: io.asciidoc In the above code you do the following: io.asciidoc Since you assume most developers reading this will be familiar with Java here is the way to implement the multipart upload in the servlet API: io.asciidoc That's what the new method of `URLImage` does: From ce4c0657cd9d8ad6722fac426db5a1e45de7a787 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:44:31 +0300 Subject: [PATCH 037/140] Continuity: tell "nothing to do" apart from "tried and failed" A provider that throws left `applied` false, and false meant "there was nothing to do" -- so the state was marked handled with none of its payload applied and nothing stored, and the relay's remaining copy was refused after the next launch. A provider throwing is not an absence of work: it happens transiently, a dependency that is not up yet during a cold launch being the ordinary cause, and the next launch may well succeed. One flag was answering two questions and had to get one of them wrong whichever way it was set. The other question is the one the previous change added it for -- whether to STORE -- and "no provider at all" genuinely is nothing to do: an application that cannot consume payloads has nothing to recover, so withholding the mark there only re-prompts the user for ever. Both are right, and they are not the same question. There are two flags now and the rule reads off them: acknowledge unless an attempt FAILED. A provider that threw, routes that were named and could not be rebuilt, and a write that was refused are failures, and in each of them the relay's copy is the only one left. No provider, an empty payload, no routes: no attempt, nothing lost, acknowledge. That also settles the unrestorable-route case, which an earlier round argued the other way. The pushback then was reasoned from the no-provider case and generalised past it; under the failure rule those routes go unacknowledged, which is what the review said. Deriving it beats adjudicating each case, and this is the fifth finding in this area to arrive at the same rule from a new direction. Suite 100/100 twice, the new test proven by reverting the catch that sets the flag. SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 43 ++++++++++++++----- .../continuity/LocalContinuityTest.java | 31 +++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 203931944b6..ec10779daa2 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -707,6 +707,13 @@ public static boolean restore(final AppState state) { // checkpoint had been. Left alone, the old checkpoint still restores and the relay may // offer this one to a build that understands it. boolean applied = false; + // Separate from `applied`, because "there was nothing to do" and "I tried and could not" + // need opposite answers and one flag cannot say both. A provider that throws is the + // second: it can happen transiently on a cold launch, when a dependency it needs is not + // up yet, and treating it as "nothing to do" marked the state handled with none of its + // payload applied and nothing stored -- so the relay's remaining copy was refused after + // the next launch and the state was gone. + boolean failed = false; StateProvider p = provider; if (p != null) { try { @@ -716,6 +723,7 @@ public static boolean restore(final AppState state) { applied = !state.getPayload().isEmpty(); } catch (Throwable t) { Log.e(t); + failed = true; } } List routes = state.getRoutes(); @@ -729,7 +737,7 @@ public static boolean restore(final AppState state) { // 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. - commit(state, applied); + commit(state, applied, failed); return false; } // Applying a state is not the user navigating, and the difference is not cosmetic. The @@ -748,7 +756,12 @@ public static boolean restore(final AppState state) { } finally { applyingRestore = false; } - commit(state, applied || shown); + if (!shown) { + // Routes were named and none could be rebuilt -- an attempt that failed, not an + // absence of work. The state stays on the relay for a launch that can use it. + failed = true; + } + commit(state, applied || shown, failed); return shown; } @@ -926,15 +939,23 @@ private static List currentRoutes() { /// longer registers, with no payload the application could take -- must not replace the /// user's own checkpoint with something unusable. /// - /// WHETHER TO ACKNOWLEDGE is not the same question. The mark is durable and stops the relay - /// ever offering this state again, so the one case that must never mark is a write that was - /// attempted and FAILED: the relay's copy is then the only copy left. Nothing to store is not - /// that case -- there is nothing to recover, the application has had the state offered to its - /// listeners, and refusing to mark it only re-prompts the user on every launch for something - /// this build cannot use. A review asked for both halves to be gated together; this is the - /// half of that finding which does not hold, and the checkpoint it was really protecting is - /// protected by `applied` above. - private static void commit(AppState state, boolean applied) { + /// WHETHER TO ACKNOWLEDGE turns on a different question: did anything FAIL. The mark is + /// durable and stops the relay ever offering this state again, so it must never follow an + /// attempt that did not work -- a provider that threw, routes that could not be rebuilt, or + /// a write that was refused. In each of those the relay's copy is the only one left. + /// + /// "Nothing to do" is not a failure, and does mean acknowledge: an application with no + /// provider can never consume a payload, so there is nothing to recover and withholding the + /// mark only re-prompts the user on every launch for ever. That distinction is why there are + /// two flags and not one -- a single "did it apply" answers both questions and gets one of + /// them wrong whichever way it is set. + private static void commit(AppState state, boolean applied, boolean failed) { + if (failed) { + // An attempt was made and it did not work: a provider that threw, or routes that + // could not be rebuilt. The relay's copy has to stay on offer for a launch that can + // use it, so nothing is marked. + return; + } if (applied && !persist(state)) { // Tried to store it and could not. The relay's copy is now the only one that exists, // so it must go on being offered: acknowledging here loses the state in both 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 index 37a8a022601..000ff51c706 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1574,6 +1574,37 @@ public void theLiveHighWaterMapIsBounded() { "the live map grew past its cap: " + Continuity.seenSizeForTest()); } + /** + * A provider that throws is an attempt that FAILED, not an absence of work. It happens + * transiently -- a dependency that is not up yet on a cold launch is the ordinary cause -- + * and marking the state handled with none of its payload applied and nothing stored left the + * relay's remaining copy refused after the next launch, so the state was gone for good. + * + *

The distinction matters because "no provider at all" must still acknowledge, or a state + * an application can never consume re-prompts for ever. Same flag once, two opposite right + * answers, which is why there are two now.

+ */ + @EdtTest + public void aProviderThatThrowsLeavesTheStateOnTheRelay() { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + throw new IllegalStateException("a dependency is not up yet"); + } + }); + + AppState arrival = fromElsewhere("provider blew up", 41L); + assertFalse(Continuity.restore(arrival), "a route-less state shows no form"); + + Map persisted = Continuity.readSeenForTest(); + assertFalse(persisted.containsKey("some-other-device"), + "a state whose provider threw was marked handled, so the relay's remaining copy " + + "is refused and none of its payload was ever applied"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From e5118074178c02a69fc69b482219fa43d0d05b65 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:00:29 +0300 Subject: [PATCH 038/140] Continuity: evict high-water marks by recency, not by sequence A brand new device's first continuation was dropped, silently. Sequences are each origin's own counter, so a phone set up this morning sending sequence 1 carries the LOWEST value in a full map -- and evicting by sequence therefore threw its mark out during the very admit() that had just written it. The dispatch queued behind admit() re-reads that mark to check nothing newer has superseded the state; finding it gone, it returned, and a perfectly good continuation vanished with nothing logged. Neither half was wrong on its own. Splitting admission from dispatch is what lets an older state notice it has been superseded, and bounding the live map is what stopped it growing for the life of the process. They only failed together, which is why two rounds of review did not see it. The comment was wrong as well: it claimed the lowest sequences are "the devices that have been quiet longest". Per-origin counters cannot say that -- a device at 5000 has been counting longer, not talking more recently -- so the code was asserting a property the data does not carry. lastSeen is a LinkedHashMap now and the trim takes the front of the iteration order, which is what the comment always claimed to be doing. Writes go through recordSeen(), which removes before putting so an active device moves to the back; the entry just written is therefore the last candidate for eviction rather than often the first. LinkedHashMap is present in both Ports/CLDC11 and vm/JavaAPI, checked before relying on it. The trim also always removes something. Scanning for a minimum from Long.MAX_VALUE selected nothing when every value equalled Long.MAX_VALUE, so the loop broke with the map still over size and the cap silently stopped applying. Taking the front cannot fail to find a victim. Suite 102/102 twice. Both tests proven by restoring the old policy: the new device's state never dispatched, and the map at 80 against a cap of 64. SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 52 ++++++++++------ .../continuity/LocalContinuityTest.java | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index ec10779daa2..4cc816ca03c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -33,6 +33,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -145,7 +147,15 @@ public final class Continuity { /// 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(); + /// Insertion-ordered, so the cap can evict the device that has been quiet longest. + /// + /// A HashMap forced the eviction to pick a victim by comparing SEQUENCES, and sequences are + /// each origin's own counter -- a device at 5000 is not busier than one at 3, it has simply + /// been counting longer. Worse, the lowest sequence in a full map is usually a device that + /// has just been set up and sent its first state, so admitting it evicted it immediately and + /// the dispatch queued behind admit() found its mark gone and dropped a perfectly good + /// continuation without a word. + private static final Map lastSeen = new LinkedHashMap(); // EDT-owned, like every field in this class. See the threading note on the class. private static StateProvider provider; @@ -219,10 +229,9 @@ public static void enable() { 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()); + recordSeen(e.getKey(), e.getValue().longValue()); } } - trimSeen(); enabled = true; ContinuityBridge b = bridgeInternal(); if (b != null) { @@ -1258,8 +1267,7 @@ private static void admit(final AppState state) { // the same state. return; } - lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); - trimSeen(); + recordSeen(state.getDeviceId(), state.getSequence()); Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1417,8 +1425,7 @@ private static void noteActedOn(AppState state) { } Long seen = lastSeen.get(from); if (seen == null || seen.longValue() < state.getSequence()) { - lastSeen.put(from, Long.valueOf(state.getSequence())); - trimSeen(); + recordSeen(from, 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 @@ -1438,7 +1445,18 @@ static int seenSizeForTest() { return lastSeen.size(); } - /// Evicts the lowest sequences until the live map is back inside MAX_SEEN. + /// Records a device's high-water mark, most recently used LAST. + /// + /// Removed before it is put back, because the map keeps INSERTION order and a plain put() + /// over an existing key leaves it where it first appeared -- so a device that has been active + /// all along would still be evicted ahead of one that has said nothing since. + private static void recordSeen(String device, long sequence) { + lastSeen.remove(device); + lastSeen.put(device, Long.valueOf(sequence)); + trimSeen(); + } + + /// Evicts the least recently seen devices until the map is back inside MAX_SEEN. /// /// The LIVE map, not a copy taken on the way to storage. A user has a handful of devices, but /// the ids arrive from a relay and nothing stops one from feeding many, which is the reason @@ -1449,18 +1467,16 @@ static int seenSizeForTest() { /// mark costs one duplicate delivery rather than anything durable. private static void trimSeen() { while (lastSeen.size() > MAX_SEEN) { - String lowest = null; - long lowestSeq = Long.MAX_VALUE; - for (Map.Entry e : lastSeen.entrySet()) { - if (e.getValue().longValue() < lowestSeq) { - lowestSeq = e.getValue().longValue(); - lowest = e.getKey(); - } - } - if (lowest == null) { + Iterator i = lastSeen.keySet().iterator(); + if (!i.hasNext()) { break; } - lastSeen.remove(lowest); + // The eldest, and ALWAYS one: the sequence comparison this replaced could select + // nothing at all -- every value equal to Long.MAX_VALUE left its "lowest" null -- and + // then simply stopped enforcing the cap. Taking the front of the iteration order + // cannot fail to find a victim while the map is over size. + i.next(); + i.remove(); } } 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 index 000ff51c706..a2f913e00d4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1605,6 +1605,65 @@ public void restoreState(Map payload) { + "is refused and none of its payload was ever applied"); } + /** + * A brand new device is not the one to evict. Sequences are each origin's own counter, so a + * device that has just been set up and sent its first state carries the LOWEST number in the + * map -- and evicting by sequence therefore threw that entry out the moment it was admitted. + * The dispatch queued behind admit() then found no mark of its own and dropped a perfectly + * good continuation with nothing logged. + */ + @EdtTest + public void aBrandNewDeviceIsNotEvictedByItsOwnArrival() { + Continuity.enable(); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + // Fill the map with established devices, all counting far higher than a new one would. + for (int i = 0; i < 64; i++) { + AppState old = new AppState() + .setDeviceId("established-" + i) + .setSequence(5000 + i) + .setTimestamp(System.currentTimeMillis()); + Continuity.deliver(old); + } + flushSerialCalls(); + seen[0] = 0; + + // A device unboxed this morning, sending its first ever state. + AppState firstEver = fromElsewhere("hello from a new phone", 1L); + Continuity.deliver(firstEver); + flushSerialCalls(); + + assertEquals(1, seen[0], + "a new device's first state was evicted by its own admission and never dispatched"); + } + + /** + * And the cap is enforced whatever the sequences are. The eviction this replaced scanned for + * the lowest value starting from Long.MAX_VALUE, so a map whose values all equalled + * Long.MAX_VALUE selected nothing and quietly stopped bounding anything at all. + */ + @EdtTest + public void theCapHoldsEvenWhenEverySequenceIsMaxValue() { + Continuity.enable(); + for (int i = 0; i < 80; i++) { + AppState s = new AppState() + .setDeviceId("maxed-" + i) + .setSequence(Long.MAX_VALUE) + .setTimestamp(System.currentTimeMillis()); + Continuity.deliver(s); + } + flushSerialCalls(); + + assertTrue(Continuity.seenSizeForTest() <= 64, + "the cap stopped being enforced: " + Continuity.seenSizeForTest()); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From 613a6091ce3f03c7b80ca0592e170753c0d45cee Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:25:52 +0300 Subject: [PATCH 039/140] Continuity: persist only completed marks, and escape the ids that carry them rememberSeen() serialized the whole in-memory map, and that map is not the set of marks that may become durable. lastSeen holds every state that was ADMITTED so a run does not dispatch the same thing twice; a state that was admitted and then failed to apply stays there deliberately. Writing the map out meant an unrelated state completing later carried the failed one to disk with it, and after a restart the relay's only usable copy was refused -- undoing, from the writer, the gating commit() performs. durableSeen is a second map holding only states this device actually completed, and it is the one that reaches storage. Splitting them broke two tests, correctly. noteActedOn() only recorded when the sequence was HIGHER than the in-memory mark, and admit() has already written that mark on the way in, so the durable half was never written at all. It had only ever worked because the writer serialized the map admit() writes to. The comment at that line already described this bug in its earlier form -- same defect, second shape, once the two sets stopped being one. It now writes the durable half explicitly, and only up to the state being completed: something newer from the same device has not been completed and must not be marked on its behalf. Device ids are escaped. The "id|seq;id|seq" format was justified by a comment asserting ids are UUIDs or a "cn1-" fallback, which is true of ids this device mints and not of ids that arrive over a relay -- setDeviceId is public and a state carries whatever it was given. Unescaped, "phone|work" produced a sequence field that would not parse, and a semicolon produced a whole second entry: a mark against an origin that never sent anything, which then suppresses that origin's real states for good. The probe shows it exactly -- the persisted key set came back as [other]. One of the two tests was vacuous when written: it restored the failing state directly, which skips admit(), so the state never entered the map and the precondition never existed. It delivers now. Both are proven by reverting the fix each covers. Suite 104/104 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 143 +++++++++++++++--- .../continuity/LocalContinuityTest.java | 68 +++++++++ 2 files changed, 188 insertions(+), 23 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 4cc816ca03c..5e2b899b5ba 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -157,6 +157,17 @@ public final class Continuity { /// continuation without a word. private static final Map lastSeen = new LinkedHashMap(); + /// The marks that are allowed to reach storage: states this device actually COMPLETED. + /// + /// Separate from `lastSeen`, which holds every state that was admitted, because those two + /// sets are not the same and writing the wrong one throws states away. A state admitted and + /// then failed -- a provider that threw, routes that could not be rebuilt -- stays in + /// lastSeen so it is not re-dispatched twice in this run, and must NOT become durable: + /// serializing the whole map meant an unrelated state completing later carried the failed + /// one to disk with it, and after a restart the relay's only usable copy was refused. That + /// is exactly the gating commit() performs, undone by the writer. + private static final Map durableSeen = new LinkedHashMap(); + // EDT-owned, like every field in this class. See the threading note on the class. private static StateProvider provider; private static StateRelay relay; @@ -229,7 +240,9 @@ public static void enable() { for (Map.Entry e : restored.entrySet()) { Long have = lastSeen.get(e.getKey()); if (have == null || have.longValue() < e.getValue().longValue()) { - recordSeen(e.getKey(), e.getValue().longValue()); + // Loaded marks describe states a previous run COMPLETED, so they are durable + // again as well as suppressing re-delivery in this one. + recordSeen(e.getKey(), e.getValue().longValue(), true); } } enabled = true; @@ -888,6 +901,7 @@ public static void clear() { // process can; what it can do is make sure nothing follows it. endRelaySession(); lastSeen.clear(); + durableSeen.clear(); // 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. @@ -1267,7 +1281,8 @@ private static void admit(final AppState state) { // the same state. return; } - recordSeen(state.getDeviceId(), state.getSequence()); + // Admission only: not durable until the state has actually been completed. + recordSeen(state.getDeviceId(), state.getSequence(), false); Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1423,15 +1438,29 @@ private static void noteActedOn(AppState state) { // Our own work needs no mark: deliver() drops an echo on the device id alone. return; } - Long seen = lastSeen.get(from); - if (seen == null || seen.longValue() < state.getSequence()) { - recordSeen(from, state.getSequence()); + long seq = state.getSequence(); + Long inMemory = lastSeen.get(from); + if (inMemory == null || inMemory.longValue() < seq) { + recordSeen(from, seq, true); + } else { + // The in-memory mark already covers this state: admit() put it there on the way in, + // which is the ordinary case, so "only if higher" never fires and the DURABLE half + // would never be written. That is the same bug the note below describes, returned in + // a new shape once the durable set stopped being the in-memory one -- writing the + // whole of memory used to hide it. + // + // Marked only up to THIS state. If something newer from the same device has been + // admitted since, it has not been completed and must not be marked on this one's + // behalf. + Long durable = durableSeen.get(from); + if (durable == null || durable.longValue() < seq) { + recordDurable(from, seq); + } } - // 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. + // ALWAYS, not only when a map moved. The condition this replaced was written when the + // durable copy tracked memory exactly; it does not, and by the time anything calls this + // memory already holds the entry, so "unchanged" meant "write nothing" and both + // acknowledge() and the restore path silently persisted nothing at all. rememberSeen(); } @@ -1450,9 +1479,20 @@ static int seenSizeForTest() { /// Removed before it is put back, because the map keeps INSERTION order and a plain put() /// over an existing key leaves it where it first appeared -- so a device that has been active /// all along would still be evicted ahead of one that has said nothing since. - private static void recordSeen(String device, long sequence) { + private static void recordSeen(String device, long sequence, boolean durable) { lastSeen.remove(device); lastSeen.put(device, Long.valueOf(sequence)); + if (durable) { + durableSeen.remove(device); + durableSeen.put(device, Long.valueOf(sequence)); + } + trimSeen(); + } + + /// Marks a device durably without disturbing the in-memory dedup mark, which may be newer. + private static void recordDurable(String device, long sequence) { + durableSeen.remove(device); + durableSeen.put(device, Long.valueOf(sequence)); trimSeen(); } @@ -1466,8 +1506,14 @@ private static void recordSeen(String device, long sequence) { /// The lowest sequences go: those are the devices that have been quiet longest, and losing a /// mark costs one duplicate delivery rather than anything durable. private static void trimSeen() { - while (lastSeen.size() > MAX_SEEN) { - Iterator i = lastSeen.keySet().iterator(); + trimTo(durableSeen); + trimTo(lastSeen); + } + + /// Evicts the least recently seen entries from one map until it is inside MAX_SEEN. + private static void trimTo(Map map) { + while (map.size() > MAX_SEEN) { + Iterator i = map.keySet().iterator(); if (!i.hasNext()) { break; } @@ -1488,20 +1534,23 @@ private static Map readSeen() { 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. + // Split on UNESCAPED separators. The ids are not all ours: setDeviceId is public and + // a state arrives from whatever the relay was given, so an id may contain the very + // characters this format is delimited by. Unescaped, "phone|work" produced a sequence + // field that would not parse and a semicolon produced a whole second entry -- a mark + // written against an origin that never sent anything, which then suppresses that + // origin's real states for good. int from = 0; - while (from < raw.length()) { - int end = raw.indexOf(';', from); + while (from <= raw.length()) { + int end = indexOfUnescaped(raw, ';', from); String entry = end < 0 ? raw.substring(from) : raw.substring(from, end); - int bar = entry.indexOf('|'); + int bar = indexOfUnescaped(entry, '|', 0); if (bar > 0 && bar < entry.length() - 1) { try { - out.put(entry.substring(0, bar), + out.put(unescapeSeenKey(entry.substring(0, bar)), Long.valueOf(Long.parseLong(entry.substring(bar + 1)))); } catch (NumberFormatException ignored) { - // Skipped, as above. + // A corrupt entry costs one duplicate delivery, never a launch. } } if (end < 0) { @@ -1515,6 +1564,53 @@ private static Map readSeen() { return out; } + /// The index of the first `c` that is not preceded by an escape, or -1. + private static int indexOfUnescaped(String s, char c, int from) { + boolean escaped = false; + for (int i = from; i < s.length(); i++) { + char at = s.charAt(i); + if (escaped) { + escaped = false; + } else if (at == '\\') { + escaped = true; + } else if (at == c) { + return i; + } + } + return -1; + } + + /// Escapes the two delimiters, and the escape itself. + private static String escapeSeenKey(String key) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if (c == '\\' || c == '|' || c == ';') { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } + + /// Reverses escapeSeenKey. + private static String unescapeSeenKey(String key) { + StringBuilder sb = new StringBuilder(); + boolean escaped = false; + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if (escaped) { + sb.append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + } else { + sb.append(c); + } + } + return sb.toString(); + } + /// Writes the high-water marks, trimmed to MAX_SEEN. /// /// Called after a delivery is accepted, which is rare -- it takes another device publishing -- @@ -1525,13 +1621,13 @@ private static void rememberSeen() { // the life of the process -- and made every acknowledgement copy the whole thing and scan // it back down to the cap, so a relay feeding many device ids cost memory and rising CPU // at once. The cap belongs where entries go IN. - Map copy = lastSeen; + Map copy = durableSeen; 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()); + sb.append(escapeSeenKey(e.getKey())).append('|').append(e.getValue().longValue()); } // ONLY the write is wrapped. Iterating a generic map compiles to checkcasts, and a // catch(Throwable) around them is a handler ParparVM never runs -- its CHECKCAST expands @@ -1659,6 +1755,7 @@ static ContinuityBridge bridgeInternal() { static void reset() { listeners.clear(); lastSeen.clear(); + durableSeen.clear(); endRelaySession(); provider = null; relay = null; 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 index a2f913e00d4..e9ad038681e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1664,6 +1664,74 @@ public void theCapHoldsEvenWhenEverySequenceIsMaxValue() { "the cap stopped being enforced: " + Continuity.seenSizeForTest()); } + /** + * A state that was admitted but never completed must not become durable on the back of an + * unrelated one. lastSeen holds every admitted state so a run does not dispatch the same + * thing twice; serializing that whole map meant a later, successful state carried the failed + * one to disk, and after a restart the relay's only usable copy was refused -- undoing, from + * the writer, exactly the gating commit() performs. + */ + @EdtTest + public void aFailedStateIsNotMadeDurableByAnUnrelatedSuccess() { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + if (payload.containsKey("boom")) { + throw new IllegalStateException("cannot apply this one"); + } + } + }); + + // DELIVERED, not restored directly: admission is what puts the state in the in-memory + // dedup map, and that is the precondition -- a state sitting in memory, never completed. + // Restoring it by hand skips admit() entirely, so the map never holds it and the test + // asserts about a situation that cannot arise. + Map boom = new HashMap(); + boom.put("boom", Boolean.TRUE); + AppState failing = new AppState().setPayload(boom).setDeviceId("device-b") + .setSequence(7L).setTimestamp(System.currentTimeMillis()); + Continuity.deliver(failing); + flushSerialCalls(); + + // A, from another device, then completes normally and writes the marks out. + Continuity.deliver(fromElsewhere("fine", 3L)); + flushSerialCalls(); + + Map persisted = Continuity.readSeenForTest(); + assertTrue(persisted.containsKey("some-other-device"), + "the state that completed should have been marked"); + assertFalse(persisted.containsKey("device-b"), + "a state that failed to apply was made durable by an unrelated success, so the " + + "relay's only usable copy is refused after a restart"); + } + + /** + * Device ids are not all ours. setDeviceId is public and a state arrives carrying whatever + * the relay was given, so an id can contain the characters the persisted format is delimited + * by. Unescaped, "phone|work" produced a sequence field that would not parse, and a semicolon + * produced a whole second entry -- a mark against an origin that never sent anything, which + * then suppresses that origin's real states for good. + */ + @EdtTest + public void aDeviceIdContainingTheDelimitersSurvivesARestart() { + Continuity.setStateProvider(new RecordingProvider()); + + String awkward = "phone|work;other"; + Map payload = new HashMap(); + payload.put("note", "hello"); + Continuity.restore(new AppState().setPayload(payload).setDeviceId(awkward) + .setSequence(12L).setTimestamp(System.currentTimeMillis())); + + Map persisted = Continuity.readSeenForTest(); + assertEquals(Long.valueOf(12L), persisted.get(awkward), + "the id did not round-trip through the persisted map: " + persisted.keySet()); + assertFalse(persisted.containsKey("other"), + "the id's semicolon invented a mark for an origin that never sent anything"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From 9149c5650099d5516fd277751bc0ba04e273a8e5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:40:32 +0300 Subject: [PATCH 040/140] Continuity: logout forgets the continuation label clear() is the documented logout path and withdrew the advertised activity, but left the label behind. The label is deliberately human-readable and belongs to the account -- the API's own examples are "Draft to Dana" and "Invoice 2031" -- and capture() reads it at every checkpoint, so the first one after a logout, a login screen or the next account's opening route, published the previous user's label again to every device around them. Withdrawing the current activity does not help: the field outlives it and the next publish puts it straight back. The line this draws is worth stating, because the next field added here will need it. clear() forgets CONTENT -- the stored checkpoint, the marks, a parked arrival, the advertised activity, and now the label. It leaves CONFIGURATION -- the provider, the relay, autoRestore, maxAge, this device's id -- because that is how the application is wired rather than what the last user was doing, and an app would otherwise have to install all of it again after every logout. Proven by reverting the fix: the label survives as "Invoice 2031 for Dana". Suite 105/105 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 11 ++++++++ .../continuity/LocalContinuityTest.java | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 5e2b899b5ba..a3a127d4f9f 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -892,6 +892,17 @@ private static void pollFinished(AppState fetched, int session) { public static void clear() { parked = null; dirty = false; + // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft + // to Dana", "Invoice 2031", read at every checkpoint -- so leaving it behind meant the + // first checkpoint after a logout, a login screen or the next account's opening route, + // re-advertised the previous user's label to every device around them. Withdrawing the + // current activity below is not enough on its own: the field outlives it and the next + // publish puts it straight back. + // + // The configuration is deliberately left alone: the provider, the relay, autoRestore, + // maxAge and this device's id are how the application is wired, not what the last user + // was doing, and an app would have to install them all again after every logout. + title = null; // Anything queued for the relay belonged to the account that just signed out, and a relay // reads its credentials when the request runs rather than when it was queued -- so a state // left here would have gone out under the NEXT account's token. The session is ended too, 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 index e9ad038681e..15ab7bdb11e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1732,6 +1732,31 @@ public void aDeviceIdContainingTheDelimitersSurvivesARestart() { "the id's semicolon invented a mark for an origin that never sent anything"); } + /** + * The continuation label is the previous user's, and clear() is the logout path. Withdrawing + * the advertised activity is not enough: the label is a field that outlives it, so the first + * checkpoint afterwards -- a login screen, or the next account's opening route -- published + * it again to every device around them. + */ + @EdtTest + public void logoutForgetsTheContinuationLabel() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setTitle("Invoice 2031 for Dana"); + Continuity.routeStackChanged(); + Continuity.checkpoint(); + + Continuity.clear(); + + assertNull(Continuity.getTitle(), "the previous account's label survived the logout"); + + // And the next account's first checkpoint must not carry it either. + Continuity.routeStackChanged(); + Continuity.checkpoint(); + assertNull(bridge.getPublishedTitle(), + "the first checkpoint after logout re-advertised the previous user's label"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From ab3b3e3cc2b42ac6a56a2629fc3781393f403cb4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:55:17 +0300 Subject: [PATCH 041/140] Continuity: stale routes do not discard a payload that applied A state can carry both a payload and routes, and those halves can land differently. When the provider took the payload but every route named in the same state had been removed from this build, the route failure was treated as fatal for the whole state -- so work that was already in the application was thrown away twice: never written to the local checkpoint, so a cold start lost it, and never acknowledged, so the relay offered the same half-usable state after every restart, re-applying the payload and failing the same routes each time. The distinction that makes this safe is the one the failure rule already rests on. A provider that throws is TRANSIENT -- a dependency that is not up yet during a cold launch -- so withholding the mark buys a retry that may work. A route this build no longer registers is DETERMINISTIC: it will not start working on the next launch, and there is nothing to retry for. So a route failure is fatal only when nothing else in the state applied; the provider case is untouched, and a route-only state that restores nothing still preserves the local checkpoint. That is the third pass over this rule -- one flag, then two, now two with the route half conditioned on the other -- and each pass was right for the cases visible at the time. This one is about PARTIAL application, which a single verdict per state could not express at all. Proven by reverting it: the applied payload never reaches the checkpoint. Suite 106/106 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 15 ++++++-- .../continuity/LocalContinuityTest.java | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index a3a127d4f9f..6aae9c96f25 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -778,9 +778,18 @@ public static boolean restore(final AppState state) { } finally { applyingRestore = false; } - if (!shown) { - // Routes were named and none could be rebuilt -- an attempt that failed, not an - // absence of work. The state stays on the relay for a launch that can use it. + if (!shown && !applied) { + // Routes were named, none could be rebuilt, and nothing else in the state applied + // either -- an attempt that failed outright, so it stays on the relay for a launch + // that can use it. + // + // Only when nothing else applied. A payload the provider took is real work, already + // in the application, and discarding it because the ROUTES are stale threw it away + // twice over: never written to the local checkpoint, so a cold start lost it, and + // never acknowledged, so the relay offered the same half-usable state after every + // restart -- re-applying the payload and failing the same routes each time. A route + // this build no longer registers will not start working on the next launch; the + // payload already worked on this one. failed = true; } commit(state, applied || shown, failed); 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 index 15ab7bdb11e..18286281beb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1757,6 +1757,40 @@ public void logoutForgetsTheContinuationLabel() { "the first checkpoint after logout re-advertised the previous user's label"); } + /** + * A payload the provider took is real work even when every route in the same state is stale. + * Treating the route failure as fatal discarded it twice: never written to the local + * checkpoint, so a cold start lost it, and never acknowledged, so the relay offered the same + * half-usable state after every restart -- re-applying the payload and failing the same + * routes each time. The routes will not start working on the next launch; the payload + * already worked on this one. + */ + @EdtTest + public void anAppliedPayloadSurvivesStaleRoutesInTheSameState() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + Map payload = new HashMap(); + payload.put("note", "the payload applied fine"); + AppState mixed = new AppState().setPayload(payload).setDeviceId("some-other-device") + .setSequence(55L).setTimestamp(System.currentTimeMillis()); + List stale = new ArrayList(); + stale.add("/a-route-this-build-does-not-register"); + mixed.setRoutes(stale); + + assertFalse(Continuity.restore(mixed), "no form can be shown for a stale route"); + + assertTrue(provider.restored.containsKey("note"), + "the provider should have been given the payload"); + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, "the applied payload never reached the local checkpoint"); + assertEquals(Long.valueOf(55L), Long.valueOf(stored.getSequence()), + "the checkpoint holds a different state than the one that was applied"); + Map persisted = Continuity.readSeenForTest(); + assertTrue(persisted.containsKey("some-other-device"), + "the state was never acknowledged, so the relay re-offers it after every restart"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From 7d385ffa2a9dbab9a5b7e31d02af8a04fb390c25 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:21:42 +0300 Subject: [PATCH 042/140] Continuity: four fixes -- parked expiry, failed capture, empty arrivals, wire tags The expiry recheck in dispatch() is a REGRESSION this branch introduced. It was the first line of dispatchLocked before the event-thread rewrite, with a comment naming the path it protects: a continuation that cold-launches the app is parked and waits up to WINDOW_WAIT_MILLIS for the first form, and the waiter then comes back through dispatch -- past the check in admit() and the one in getRestorableState(). A state that was fresh when it landed and expired during the wait was restored anyway, which is exactly what maxAge exists to refuse for a checkout or a booking hold. Restored, with that history in the comment. A provider that throws no longer costs the stored payload. It leaves the captured state with no payload, and writing that over the last checkpoint loses a draft that was safely stored a moment earlier, for a read that may well succeed next time. The last successfully read payload is CARRIED FORWARD instead, so the checkpoint keeps the newest routes and the newest payload that ever read cleanly -- what the application would have written had it checkpointed just before the failure -- and `dirty` stays set so a later suspend retries. Two earlier attempts at that traded one loss for another: skipping the write loses the current routes, writing loses the stored payload. The second attempt only skipped EMPTY states, and a test caught it -- but only when the whole suite ran, because another class had left a navigation stack behind so the state was not empty. That is not fixture pollution; it is an application with routes whose provider threw, and it is the case carrying forward handles and neither earlier attempt did. An empty arrival is consumed as a tombstone. An enabled app with no routes and no payload still checkpoints, and the relay holds one document per user, so the empty state is published to overwrite the stale one -- which is the point. It carries a device id and a sequence though, so the receiving side ran the listeners over it: a "continue what you were doing?" prompt about nothing. The platform path already withdrew the activity for an empty state; only the relay half was missing it. The wire format says once whether its values are tagged. decode() asked of every string whether it LOOKED tagged, so an untagged payload -- what a hand-written endpoint or an older build sends, and which this codec deliberately accepts -- had the ordinary string "i:5" turned into an Integer and "s:note" stripped to "note". No per-string rule can separate those, because "i:5" is a perfectly good string. Documents this codec writes carry enc=1; anything without it is read exactly as it arrived. Changed now because nothing has shipped. The expiry test was vacuous when first written: it delivered the same state twice and the second was refused by the in-memory mark long before reaching dispatch. It drives the parked slot and the waiter's own drain now, through two seams, because the wait needs a launch with no form and this harness always has one. All four proven by reverting each fix alone. Suite 111/111 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 92 +++++++++++++++- .../com/codename1/continuity/StateCodec.java | 36 +++++- .../continuity/AppStateWireTest.java | 39 +++++++ .../continuity/LocalContinuityTest.java | 104 ++++++++++++++++++ 4 files changed, 264 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6aae9c96f25..066a09857ae 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -521,11 +521,41 @@ public static void checkpoint() { if (!enabled) { return; } - dirty = false; - AppState state = capture(); + boolean[] providerFailed = new boolean[1]; + AppState state = capture(providerFailed); if (state == null) { return; } + if (providerFailed[0]) { + // The last payload is CARRIED FORWARD rather than replaced by nothing. + // + // A provider that throws leaves this state with no payload, and writing that over a + // stored draft loses it -- the very thing a checkpoint exists to prevent, caused by a + // read that may well succeed next time. Skipping the write instead was the first + // attempt and it traded one loss for another: the rule beside this one is that a + // provider failure costs its own payload and NOTHING MORE, so the routes, which are + // current and real, have to be saved. + // + // Carrying forward gives up neither. The stored checkpoint keeps the newest routes + // and the newest payload that was ever successfully read, which is exactly what the + // application would have written had it checkpointed a moment before the failure. + // + // Found by a test failing only when the whole suite ran: another class had left a + // navigation stack behind, so the state was not empty, and an earlier version of this + // guard -- which only skipped EMPTY states -- wrote routes over the draft. That is + // not a fixture artifact; it is an application with routes whose provider threw. + AppState previous = readStored(); + if (previous != null && !previous.getPayload().isEmpty()) { + state.setPayloadUnchecked(previous.getPayload()); + } + // Still owed either way, so a later suspend retries the capture that failed. + dirty = true; + persist(state); + publishContinuation(state); + publishToRelay(state); + return; + } + dirty = false; if (!persist(state)) { // Still owed. `dirty` was cleared on the way in, and leaving it clear told the next // suspend there was nothing to write -- so a checkpoint that failed on a full disk @@ -566,6 +596,14 @@ public static boolean isCheckpointPending() { /// /// - `IllegalArgumentException`: when the provider returned an unrepresentable payload public static AppState capture() { + // Best effort, which is what this method has always been: an application calling it to + // feed its own transport wants whatever can be gathered. checkpoint() asks the private + // form instead, because for the DURABLE path a provider failure is not "no payload". + return capture(new boolean[1]); + } + + /// As above, reporting through `providerFailed` whether the provider threw. + private static AppState capture(boolean[] providerFailed) { if (!enabled) { return null; } @@ -579,8 +617,16 @@ public static AppState capture() { } 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. + // are still saved and the payload is simply absent from THIS state. + // + // Reported, though, because "absent" and "could not be gathered" are different + // answers and the caller decides what to do with them. A payload-only app whose + // provider threw would otherwise checkpoint an EMPTY state over the last good + // one, publish it, and clear the pending flag -- so a process death lost the + // draft that was safely stored a moment earlier, because of a failure that may + // well be transient. Log.e(t); + providerFailed[0] = true; } if (payload != null) { // NOT caught. An unrepresentable value is a programming error with exactly one @@ -1303,6 +1349,20 @@ private static void admit(final AppState state) { } // Admission only: not durable until the state has actually been completed. recordSeen(state.getDeviceId(), state.getSequence(), false); + if (state.isEmpty()) { + // A TOMBSTONE, not an offer. An enabled app with no routes and no payload still + // checkpoints, and the relay holds one document per user, so that empty state is + // published to overwrite whatever was there -- which is the point, it clears the + // other devices' stale copy. It carries a device id and a sequence, though, so the + // receiving side recognized it as a real arrival and ran the listeners: a + // "continue what you were doing?" prompt over nothing at all. + // + // The platform path has always got this right -- publishContinuation() withdraws the + // activity for an empty state rather than advertising one -- and only the relay path + // was missing the other half of it. Marked as seen above, so it is consumed rather + // than reconsidered, and simply not dispatched. + return; + } Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1324,6 +1384,18 @@ public void run() { /// Applies an arrival: offers it to the listeners, then restores or parks it. private static void dispatch(AppState state) { + if (isTooOld(state)) { + // Rechecked HERE, not only at admission, because admission is not the only way in. + // A continuation that cold-launches the app is parked and waits up to + // WINDOW_WAIT_MILLIS for the first form, and the waiter then comes back through this + // method -- so a state that was fresh when it landed and expired during the wait was + // restored anyway, past the check in admit() and the one in getRestorableState(). + // An expired checkout or booking hold is exactly what maxAge exists to refuse. + // + // This check existed before the event-thread rewrite and was dropped by it. Its + // comment named this path. + return; + } if (Display.getInstance().getCurrent() == null) { // A continuation can cold-launch the app, and both Apple delegates hand it over while // init/start are still queued. Restoring against no form at all would run the route @@ -1484,6 +1556,20 @@ private static void noteActedOn(AppState state) { rememberSeen(); } + /// Test seam: parks a state, as a cold-launch arrival with no form yet does. + static void parkForTest(AppState state) { + parked = state; + } + + /// Test seam: the cold-launch drain, entered exactly where the waiter enters it. + /// + /// The wait itself cannot be reproduced in a unit harness -- it needs a launch with no form, + /// and this one always has one -- but the drain is the half that matters: it is where a state + /// that was fresh when it arrived and expired while waiting reaches dispatch(). + static void drainParkedForTest() { + windowWaitFinished(); + } + /// Test seam: the marks as they would be reloaded on the next launch. static Map readSeenForTest() { return readSeen(); diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 05969354eb8..1d8881e3b89 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -45,6 +45,23 @@ public final class StateCodec { private static final String KEY_ROUTES = "routes"; private static final String KEY_PAYLOAD = "payload"; + + /// Says that this document's payload values carry type tags. + /// + /// The decision has to be made once for the DOCUMENT, not guessed per value. decode() used to + /// ask of every string whether it looked tagged, so an untagged payload -- what a + /// hand-written endpoint or an older build produces, and which this codec deliberately + /// accepts -- had any ordinary string of the form "i:5" or "s:note" silently reinterpreted: + /// the first became an Integer, the second lost its prefix. That is application data changed + /// in transit, and no per-string rule can tell the two apart, because "i:5" is a perfectly + /// good string. + /// + /// A document written by this codec says so. One without the marker is read exactly as it + /// arrived. + private static final String KEY_ENCODING = "enc"; + + /// The only encoding this codec writes. Absent means untagged. + private static final String ENCODING_TAGGED = "1"; private static final String KEY_DEVICE = "device"; private static final String KEY_TITLE = "title"; private static final String KEY_SEQUENCE = "seq"; @@ -72,6 +89,7 @@ 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_ENCODING, ENCODING_TAGGED); m.put(KEY_DEVICE, state.getDeviceId()); if (state.getTitle() != null) { m.put(KEY_TITLE, state.getTitle()); @@ -125,13 +143,18 @@ public static AppState fromMap(Map m) { } state.setRoutes(paths); } + // Whether the values are tagged is the DOCUMENT's answer, not a guess made per string. + // See KEY_ENCODING: without it, "i:5" is just a string and stays one. + Object encoding = m.get(KEY_ENCODING); + boolean tagged = encoding instanceof String && ENCODING_TAGGED.equals(encoding); Object payload = m.get(KEY_PAYLOAD); if (payload instanceof Map) { Map copy = new HashMap(); Map read = (Map) payload; for (Map.Entry entry : read.entrySet()) { if (entry.getKey() instanceof String) { - copy.put((String) entry.getKey(), decode(entry.getValue())); + copy.put((String) entry.getKey(), + tagged ? decode(entry.getValue()) : entry.getValue()); } } // Not validated on the way in. This map came from another device, and refusing it @@ -288,9 +311,14 @@ private static Map encode(Map payload) { /// 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. + /// Only ever called for a document that DECLARED its values tagged, through KEY_ENCODING. A + /// document without that marker -- a hand-written endpoint, or a build older than the + /// tagging -- is passed through untouched by the caller, because there is no way to tell an + /// encoded "i:5" from a string whose value happens to be "i:5", and guessing corrupts the + /// second to rescue the first. + /// + /// A value that is untagged INSIDE a tagged document is still passed through: nested + /// containers are walked, and anything unrecognized is more useful untyped than discarded. private static Object decode(Object value) { if (value instanceof List) { List in = (List) value; 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 index 6e4711149a9..bffc38510db 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -527,4 +527,43 @@ public void execute() { assertTrue(e.getMessage().length() < 2000, "the message reproduced the whole key: " + e.getMessage().length() + " chars"); } + + /** + * An untagged payload is read exactly as it arrived. decode() used to ask of every string + * whether it looked tagged, so a hand-written endpoint or an older build sending the ordinary + * string "i:5" had it turned into an Integer, and "s:note" silently lost its prefix. No + * per-string rule can separate those, because "i:5" is a perfectly good string -- the + * document says once whether its values are tagged. + */ + @Test + public void anUntaggedPayloadKeepsStringsThatLookLikeTags() { + Map payload = new HashMap(); + payload.put("looksLikeAnInt", "i:5"); + payload.put("looksLikeAString", "s:note"); + Map doc = new HashMap(); + doc.put("device", "some-other-device"); + doc.put("seq", "3"); + doc.put("payload", payload); + // No "enc" marker: this is what a hand-written endpoint produces. + + AppState state = StateCodec.fromMap(doc); + + assertNotNull(state); + assertEquals("i:5", state.getPayload().get("looksLikeAnInt")); + assertEquals("s:note", state.getPayload().get("looksLikeAString")); + } + + /** And a document this codec wrote still round-trips its types. */ + @Test + public void aTaggedDocumentStillRoundTripsItsTypes() { + Map payload = new HashMap(); + payload.put("count", Integer.valueOf(5)); + payload.put("note", "i:5"); + AppState state = new AppState().setPayload(payload).setDeviceId("d").setSequence(1L); + + AppState back = StateCodec.fromMap(StateCodec.toMap(state)); + + assertEquals(Integer.valueOf(5), back.getPayload().get("count")); + assertEquals("i:5", back.getPayload().get("note")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java index 18286281beb..5767121b74a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1791,6 +1791,110 @@ public void anAppliedPayloadSurvivesStaleRoutesInTheSameState() { "the state was never acknowledged, so the relay re-offers it after every restart"); } + /** + * A state that was fresh when it landed but expired while waiting for the first form must not + * be restored. The cold-launch waiter comes back through dispatch() up to WINDOW_WAIT_MILLIS + * later, past the check in admit() and the one in getRestorableState() -- so an expired + * checkout or booking hold was auto-restored anyway. That check existed before the + * event-thread rewrite and the rewrite dropped it. + * + *

Driven through the parked slot and the waiter's own drain, because the wait cannot be + * reproduced here: it needs a launch with no form and this harness always has one. An earlier + * version of this test delivered the same state twice and asserted nothing at all -- the + * second delivery was refused by the in-memory mark long before it could reach dispatch.

+ */ + @EdtTest + public void aStateThatExpiresWhileParkedIsNotRestored() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + // Parked while it was fresh, and older than the limit by the time the waiter drains it. + AppState aged = fromElsewhere("stale by the time a form appeared", 61L); + aged.setTimestamp(System.currentTimeMillis() - 5000L); + Continuity.setMaxAge(1000L); + Continuity.parkForTest(aged); + + Continuity.drainParkedForTest(); + flushSerialCalls(); + + assertEquals(0, seen[0], + "a state that expired while it was parked was restored anyway"); + } + + /** + * An empty document is a tombstone, not an offer. An enabled app with no routes and no + * payload still checkpoints, and the relay holds one document per user, so that empty state + * is published to overwrite the stale one -- which is the point. It carries a device id and a + * sequence though, so the receiving side ran the listeners over it: a "continue what you were + * doing?" prompt about nothing. The platform path already withdrew the activity for an empty + * state; only the relay half was missing it. + */ + @EdtTest + public void anEmptyArrivalIsConsumedRatherThanOffered() { + Continuity.setStateProvider(new RecordingProvider()); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + AppState empty = new AppState().setDeviceId("some-other-device").setSequence(71L) + .setTimestamp(System.currentTimeMillis()); + Continuity.deliver(empty); + flushSerialCalls(); + + assertEquals(0, seen[0], + "an empty state was offered to the listeners, prompting the user over nothing"); + } + + /** + * A provider that throws must not replace a stored draft with an empty state. It leaves the + * state with no payload, and an app with no routes has nothing else in it -- so the write + * destroyed what was safely stored a moment ago, cleared the pending flag so no later suspend + * retried, and withdrew the platform continuation, all for a read that may succeed next time. + */ + @EdtTest + public void aFailingProviderDoesNotWipeAStoredDraft() { + final boolean[] blowUp = new boolean[1]; + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + if (blowUp[0]) { + throw new IllegalStateException("cannot read the draft right now"); + } + Map m = new HashMap(); + m.put("draft", "half a sentence"); + return m; + } + + public void restoreState(Map payload) { + } + }); + + Continuity.checkpoint(); + assertEquals("half a sentence", + Continuity.getRestorableState().getPayload().get("draft"), + "the draft should have been stored"); + + blowUp[0] = true; + Continuity.checkpoint(); + + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, "the stored draft was destroyed by a provider that threw"); + assertEquals("half a sentence", stored.getPayload().get("draft"), + "an empty state was written over the stored draft"); + assertTrue(Continuity.isCheckpointPending(), + "the failed capture left nothing owed, so no later suspend retries it"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From ba5b42a57127aacab543140fe98d6d54a17ace8d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:56:43 +0300 Subject: [PATCH 043/140] Continuity: setBridge installs the callback through refreshBridge setBridge() installed the callback itself, under `b != null && enabled`, while refreshBridge() ten lines below did the same job with both conditions right. It is now the fields plus a delegation, and the second weaker copy is gone. Both halves of that condition were wrong. `b != null` skipped the case that most needs it. setBridge(null) hands resolution back to the PLATFORM, and the bridge resolution then produces is a different object that has never been given a callback -- so outbound calls kept working and the seam looked healthy while every inbound continuation and synced-store notification went nowhere. `enabled` is not the right question either. A sync-only application installs the inbound seam through SyncedStore.addChangeListener and deliberately leaves continuity off -- a key/value store is not consent to broadcast a route stack -- so storeCallbackInstalled is true while enabled is false, and a bridge swap left it with no callback at all. One test, not two. The sync-only half is observable here and catches the old condition. The setBridge(null) half is not: core-unittests has no platform bridge for resolution to find, and a test written for it passed against the broken code too, because the fixture's bridge still held the callback installed at enable(). It asserted nothing, so it was removed and replaced by a comment saying why -- a test that cannot fail is worse than none, because it reads as cover. Suite 112/112, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 21 ++++++---- .../continuity/LocalContinuityTest.java | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 066a09857ae..847ea435fe0 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1768,13 +1768,20 @@ private static long nextSequence() { public static void setBridge(ContinuityBridge b) { bridge = b; bridgeOverridden = b != null; - if (b != null && enabled) { - try { - b.setCallback(new Callback()); - } catch (Throwable t) { - Log.e(t); - } - } + // Delegated, not repeated. This used to install the callback itself under + // `b != null && enabled`, and both halves of that were wrong while refreshBridge() -- + // ten lines below, doing the same job -- had them right. + // + // `b != null` skipped the case that needs it most: setBridge(null) hands resolution back + // to the PLATFORM, and the bridge it then resolves is a different object that has never + // been given a callback. Outbound calls kept working, so the seam looked healthy while + // every inbound continuation and synced-store notification went nowhere. + // + // `enabled` is not the right question either. A sync-only application installs the + // inbound seam through SyncedStore.addChangeListener and deliberately leaves continuity + // off, so storeCallbackInstalled is true while enabled is false -- and it got no callback + // at all. + refreshBridge(); } /// Internal. The resolved platform bridge, for `com.codename1.continuity.sync`, which is a 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 index 5767121b74a..c9e4519d103 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1895,6 +1895,46 @@ public void restoreState(Map payload) { "the failed capture left nothing owed, so no later suspend retries it"); } + /* + * There is deliberately NO test here for setBridge(null) handing resolution back to the + * platform. The fix covers it -- refreshBridge() resolves override-or-platform and installs + * the callback on whichever it picks -- but core-unittests has no platform bridge for + * resolution to find, so nothing observable changes in this harness. A test written for it + * passed against the BROKEN code too, because the fixture's bridge still held the callback + * installed at enable(): it asserted nothing and was removed rather than left looking like + * cover. The sibling below exercises the other half of the same condition, and does catch it. + */ + + + /** + * And a sync-only client gets one too. It installs the inbound seam through + * SyncedStore.addChangeListener and deliberately leaves continuity off -- a key/value store + * is not consent to broadcast a route stack -- so gating the callback on `enabled` left it + * with none, and store changes made on another device never reached its listener. + */ + @EdtTest + public void aSyncOnlyClientGetsTheCallbackOnABridgeSwap() { + final int[] changes = new int[1]; + SyncedStoreListener l = new SyncedStoreListener() { + public void storeChanged() { + changes[0]++; + } + }; + registered.add(l); + SyncedStore.addChangeListener(l); + assertFalse(Continuity.isEnabled(), + "registering a store listener must not enable continuity"); + + LocalContinuityBridge replacement = new LocalContinuityBridge(); + Continuity.setBridge(replacement); + + replacement.simulateStoreChange(); + flushSerialCalls(); + + assertEquals(1, changes[0], + "a sync-only client got no callback on the replacement bridge"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full From f91c31b402cf4607faf2fad8df22d07de5e07bcf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:31:31 +0300 Subject: [PATCH 044/140] Continuity: verify the sequence write, separate the simulator origins, drop the sample The sequence counter is read back rather than trusted. Preferences.set() returns void and swallows the underlying Storage.writeObject() result, so a refused write -- a full disk being the ordinary cause -- is indistinguishable from a successful one. That silence is expensive on this value in particular: the counter reloads lower after a restart, and every receiving device whose high-water mark already includes the higher number refuses this device's states until the counter climbs past it again. States simply stop arriving on the other device, with nothing logged on either. A counter that cannot be read back now marks the capture as not durable, which is the path a provider that throws already takes. The flag carrying that answer is called captureFailed now. It was providerFailed and had quietly acquired a second meaning, which is how a caller ends up reasoning about the wrong question. The simulator's canned arrivals get their own origins. They stamped System.currentTimeMillis() as a sequence -- around 1.7e12 -- onto "simulated- device", which is the same id LocalContinuityBridge.simulateArrival() puts on the application's real checkpoint for "continue on this device". One click of any canned item therefore recorded a high-water mark the genuine item could never beat, so it was refused as stale for the rest of the process, and for good once a canned state was durably acknowledged. The menu item demonstrating the feature disabled the menu item demonstrating the feature. Samples/samples/ContinuitySample is deleted. It was never asked for, and adding it was scope I took on my own. The review finding it attracted -- that TextArea.getCursorPosition() always returns -1, so the sample's caret capture stored nothing and its restore applied nothing -- was correct about the code and only existed because the file did. Suite 112/112, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, javase builds, copyright, control-character, cast and missing-code-block gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 41 ++- .../impl/javase/ContinuitySimulatorHooks.java | 20 +- .../ContinuitySample/ContinuitySample.java | 246 ------------------ .../codenameone_settings.properties | 7 - 4 files changed, 50 insertions(+), 264 deletions(-) delete mode 100644 Samples/samples/ContinuitySample/ContinuitySample.java delete mode 100644 Samples/samples/ContinuitySample/codenameone_settings.properties diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 847ea435fe0..1ce6943432c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -521,12 +521,12 @@ public static void checkpoint() { if (!enabled) { return; } - boolean[] providerFailed = new boolean[1]; - AppState state = capture(providerFailed); + boolean[] captureFailed = new boolean[1]; + AppState state = capture(captureFailed); if (state == null) { return; } - if (providerFailed[0]) { + if (captureFailed[0]) { // The last payload is CARRIED FORWARD rather than replaced by nothing. // // A provider that throws leaves this state with no payload, and writing that over a @@ -602,8 +602,13 @@ public static AppState capture() { return capture(new boolean[1]); } - /// As above, reporting through `providerFailed` whether the provider threw. - private static AppState capture(boolean[] providerFailed) { + /// As above, reporting through `captureFailed` whether anything went wrong gathering the + /// state -- the provider throwing, or the sequence counter failing to reach disk. + /// + /// Named for the QUESTION rather than one of its causes. It began as "providerFailed" and + /// then acquired a second meaning, which is the sort of drift that makes a caller reason + /// about the wrong thing. + private static AppState capture(boolean[] captureFailed) { if (!enabled) { return null; } @@ -626,7 +631,7 @@ private static AppState capture(boolean[] providerFailed) { // draft that was safely stored a moment earlier, because of a failure that may // well be transient. Log.e(t); - providerFailed[0] = true; + captureFailed[0] = true; } if (payload != null) { // NOT caught. An unrepresentable value is a programming error with exactly one @@ -643,7 +648,12 @@ private static AppState capture(boolean[] providerFailed) { // 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); + if (!rememberSequence(seq)) { + // Treated exactly like a provider that threw: the state is not durable, so the caller + // keeps the checkpoint owed and does not publish a sequence that this device cannot + // prove it will still be past after a restart. + captureFailed[0] = true; + } state.setDeviceId(getDeviceId()) .setSequence(seq) .setTimestamp(System.currentTimeMillis()) @@ -1075,11 +1085,26 @@ private static boolean persist(AppState state) { } /// Writes the sequence counter so it keeps rising across a relaunch. - private static void rememberSequence(long seq) { + /// Persists the sequence counter, and says whether it is actually on disk. + /// + /// Read back rather than trusted. Preferences.set() returns void and swallows the underlying + /// Storage.writeObject() result, so a refused write -- a full disk is the ordinary cause -- + /// looks exactly like a successful one from here. That silence is expensive on this + /// particular value: the counter reloads lower after a restart, and every receiving device + /// whose high-water mark already includes the higher number refuses this device's states + /// until the counter climbs past it again. States stop arriving, on the other device, with + /// nothing logged on either. + /// + /// #### Returns + /// + /// true when the counter can be read back + private static boolean rememberSequence(long seq) { try { Preferences.set(PREF_SEQUENCE, seq); + return Preferences.get(PREF_SEQUENCE, (long) 0) == seq; } catch (Throwable t) { Log.e(t); + return false; } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java index 14cad9821ef..58b01be877e 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java @@ -51,6 +51,20 @@ public final class ContinuitySimulatorHooks { private ContinuitySimulatorHooks() { } + /* + * Each canned arrival above has its OWN origin id, and none of them is "simulated-device". + * + * That is the id LocalContinuityBridge.simulateArrival() stamps on the app's real checkpoint + * for "continue on this device", and these states carry a sequence of + * System.currentTimeMillis() -- around 1.7e12, against the small counter a real checkpoint + * uses. Sharing the id meant one click of any item here recorded a high-water mark that the + * genuine "Continue Here" could never beat, so it was silently refused as stale for the rest + * of the process, and for good once a canned state was durably acknowledged. The menu item + * that demonstrates the feature broke the menu item that demonstrates the feature. + * + * Separate origins rather than a shared counter: these are meant to look like different + * devices anyway, and nothing here needs them ordered against each other. + */ private static LocalContinuityBridge bridge() { return JavaSEPort.getSimulatedContinuity(); } @@ -75,7 +89,7 @@ public static void continueWithAStaleRoute() { List routes = new ArrayList(); routes.add("/a-route-this-build-no-longer-has"); state.setRoutes(routes) - .setDeviceId("simulated-device") + .setDeviceId("simulated-older-build") .setSequence(System.currentTimeMillis()) .setTimestamp(System.currentTimeMillis()) .setTitle("From a older build"); @@ -93,7 +107,7 @@ public static void continuePayloadOnly() { Map payload = new HashMap(); payload.put("simulated", Boolean.TRUE); state.setPayload(payload) - .setDeviceId("simulated-device") + .setDeviceId("simulated-payload-only") .setSequence(System.currentTimeMillis()) .setTimestamp(System.currentTimeMillis()) .setTitle("Payload only"); @@ -107,7 +121,7 @@ public static void continuePayloadOnly() { public static void continueSomethingStale() { AppState state = new AppState(); state.setRoutes(currentRoutes()) - .setDeviceId("simulated-device") + .setDeviceId("simulated-yesterday") .setSequence(System.currentTimeMillis()) .setTimestamp(System.currentTimeMillis() - 86400000L) .setTitle("From yesterday"); diff --git a/Samples/samples/ContinuitySample/ContinuitySample.java b/Samples/samples/ContinuitySample/ContinuitySample.java deleted file mode 100644 index 294519c9d8a..00000000000 --- a/Samples/samples/ContinuitySample/ContinuitySample.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * 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.events.DataChangedListener; -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(); - } else { - // Declining is recorded, not just acted on. Returning false alone suppresses - // the state for this run only -- false also means "hold it, I will ask again" - // -- so without this the relay's unchanged document re-prompts after every - // relaunch. - Continuity.acknowledge(state); - } - // 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); - // A DATA CHANGE listener, not an action listener. TextArea.addActionListener fires when - // editing ENDS -- its own javadoc warns it "might never fire an action event if it is - // edited in place and the user never leaves the text field" -- so a draft that is still - // being typed was never captured, and the sample lost precisely the half-finished draft - // it exists to preserve: killed mid-sentence, or picked up on another device, the state - // held whatever was there the last time focus left the field. - // - // Checkpointing per keystroke is the point rather than an oversight. This feature is - // write-through by design -- there is no reliable "save on exit" callback to hang it on, - // which the chapter says outright -- and an app whose whole state is one text field has - // nothing else that would ever checkpoint. - field.addDataChangeListener(new DataChangedListener() { - public void dataChanged(int type, int index) { - 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 deleted file mode 100644 index a90409a0937..00000000000 --- a/Samples/samples/ContinuitySample/codenameone_settings.properties +++ /dev/null @@ -1,7 +0,0 @@ -#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. From 558c49c0835ac22df56aca776f851a0ca7e5ca49 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:14:06 +0300 Subject: [PATCH 045/140] Continuity: durable values go through a write that can fail, and a parked arrival is not published over The sequence verification added in the previous commit was worthless, and this replaces it. Preferences.set() puts the value in a static Hashtable, then calls save(), which discards Storage.writeObject()'s result -- and Preferences.get() reads that same Hashtable. Reading a value back after writing it therefore confirms the cache and says nothing whatever about the disk, so the check could not detect the failure it was written for. Every value here whose meaning is durability now goes through Storage, which returns a boolean. The sequence, because a counter that reloads lower has every receiving device refusing this one until it catches up. The delivery marks, because a mark that never lands lets an acknowledged state be acted on twice after a restart -- that one is logged and retried rather than treated as fatal, since a duplicate delivery is the recoverable direction. And the device id, which was not in the review but is the same failure and the worst of the three: a failed write means the next launch mints a different id and every state this device has ever sent stops being recognized as its own. A checkpoint no longer publishes over an arrival the user is still deciding about. The relay holds one document per user and a parked state exists ONLY in memory -- autoRestore off, or a listener returning false to prompt first -- so publishing replaces the last copy of it anywhere, and a process death while the prompt is on screen loses it outright. The publication is held rather than dropped: accepting, acknowledging, logging out or expiring all clear the slot and call back in, and an acknowledged state is safe to overwrite because its mark is already durable. startPublisher() reads the parked slot into a local before testing it. PMD's NonThreadSafeSingleton matches the shape of "null-check a static, then assign a static in the branch" and the gate has no per-finding allow list, so the shape is what has to change. Both fixes proven by reverting each alone, the second by putting the marks back on Preferences rather than by deleting the write -- deleting it reproduces a different bug and reported the wrong answer first time round. Suite 114/114 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 102 +++++++++++++++--- .../continuity/LocalContinuityTest.java | 84 ++++++++++++++- 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1ce6943432c..1a942ca8b87 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -25,7 +25,6 @@ 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; @@ -120,6 +119,19 @@ public final class Continuity { /// Where this installation's device id lives, so a state can recognize its own echo across /// restarts. + /// Every value below lives in Storage, not Preferences, and the names are kept only because + /// they are what the data was already called. + /// + /// Preferences cannot say whether a write reached the disk. set() puts the value in a static + /// Hashtable and then calls save(), which discards Storage.writeObject()'s result -- and + /// get() reads that same Hashtable, so reading a value back after writing it confirms the + /// cache and nothing else. A verification built that way was added here once and could not + /// detect the failure it was written for. + /// + /// These three all have durable meaning: an id that changes across a restart makes every + /// state this device sent look foreign, a counter that reloads lower has receivers refusing + /// this device until it catches up, and a mark that never lands lets an acknowledged state be + /// acted on twice. Storage.writeObject returns a boolean, so the failure is at least visible. static final String PREF_DEVICE_ID = "CN1$ContinuityDevice"; /// Where the sequence counter lives. Persisted because a counter that restarted at zero would @@ -734,6 +746,9 @@ public static boolean restore() { // to the provider and returns false, so every later call re-applied the same state. if (isSameState(parked, state)) { parked = null; + // The slot is what holds a publication back; the decision has been made, so anything + // waiting on it can go out now. + startPublisher(); } return shown; } @@ -1087,21 +1102,26 @@ private static boolean persist(AppState state) { /// Writes the sequence counter so it keeps rising across a relaunch. /// Persists the sequence counter, and says whether it is actually on disk. /// - /// Read back rather than trusted. Preferences.set() returns void and swallows the underlying - /// Storage.writeObject() result, so a refused write -- a full disk is the ordinary cause -- - /// looks exactly like a successful one from here. That silence is expensive on this - /// particular value: the counter reloads lower after a restart, and every receiving device - /// whose high-water mark already includes the higher number refuses this device's states - /// until the counter climbs past it again. States stop arriving, on the other device, with - /// nothing logged on either. + /// Written through Storage, whose writeObject() reports failure, rather than through + /// Preferences, which cannot. + /// + /// An earlier attempt at this wrote through Preferences and then read the value back to + /// check. That verifies nothing: Preferences.set() puts the value in a static Hashtable + /// before calling save(), save() discards Storage.writeObject()'s result, and + /// Preferences.get() reads the same Hashtable -- so the read-back returns what was just put + /// there whether or not any of it reached the disk. + /// + /// The silence matters on this value: the counter reloads lower after a restart, and every + /// receiving device whose high-water mark already includes the higher number refuses this + /// device's states until the counter climbs past it again. States stop arriving on the other + /// device, with nothing logged on either. /// /// #### Returns /// - /// true when the counter can be read back + /// true when the counter reached storage private static boolean rememberSequence(long seq) { try { - Preferences.set(PREF_SEQUENCE, seq); - return Preferences.get(PREF_SEQUENCE, (long) 0) == seq; + return Storage.getInstance().writeObject(PREF_SEQUENCE, Long.valueOf(seq)); } catch (Throwable t) { Log.e(t); return false; @@ -1211,6 +1231,24 @@ private static void startPublisher() { // after the application turned it off. return; } + // Read into a local before the test. PMD's NonThreadSafeSingleton matches the SHAPE of + // "null-check a static, then assign a static inside the branch" and reports it as a lazy + // initializer, which this is not -- and the project's gate has no per-finding allow list, + // so the shape is what has to change. + AppState awaitingDecision = parked; + if (awaitingDecision != null) { + // A fetched state is waiting on the user and has NOT been acknowledged. The relay + // holds one document per user, so publishing now replaces the only copy of it that + // exists anywhere -- it is in memory here and nowhere else -- and a process death + // while the prompt is on screen loses it for good. autoRestore off, or a listener + // that returns false to ask first, is the ordinary way to get here. + // + // Held rather than dropped. Whatever clears the slot -- the user accepting, the + // application acknowledging, a logout, the state expiring -- calls back in here, and + // an acknowledged state is safe to overwrite because the mark is already durable. + publishRequested = true; + return; + } if (publishing) { // Asked BEFORE the empty-slot check, which is the whole point of the flag. A worker // that is out has already taken the state out of the slot, so the slot is empty @@ -1515,14 +1553,29 @@ private static void windowWaitFinished() { if (waiting != null) { dispatch(waiting); } + // Whether it dispatched or was refused, the slot is no longer holding anything back. + startPublisher(); } private static String loadDeviceId() { try { - String id = Preferences.get(PREF_DEVICE_ID, null); + String id = null; + if (Display.isInitialized() && Storage.getInstance().exists(PREF_DEVICE_ID)) { + Object o = Storage.getInstance().readObject(PREF_DEVICE_ID); + if (o instanceof String) { + id = (String) o; + } + } if (id == null || id.length() == 0) { id = Util.getUUID(); - Preferences.set(PREF_DEVICE_ID, id); + if (!Storage.getInstance().writeObject(PREF_DEVICE_ID, id)) { + // Minted but not stored, so the next launch mints another one and every state + // this device has sent starts looking like a stranger's. Nothing here can + // prevent that; saying so beats a silent identity change. + Log.p("Continuity: the device id could not be stored; it will change on the " + + "next launch and states already sent will not be recognized as " + + "this device's own."); + } } return id; } catch (Throwable t) { @@ -1661,7 +1714,11 @@ private static void trimTo(Map map) { private static Map readSeen() { Map out = new HashMap(); try { - String raw = Preferences.get(PREF_SEEN, ""); + if (!Display.isInitialized() || !Storage.getInstance().exists(PREF_SEEN)) { + return out; + } + Object stored = Storage.getInstance().readObject(PREF_SEEN); + String raw = stored instanceof String ? (String) stored : null; if (raw == null || raw.length() == 0) { return out; } @@ -1766,7 +1823,14 @@ private static void rememberSeen() { // 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()); + if (!Storage.getInstance().writeObject(PREF_SEEN, sb.toString())) { + // The marks stay in memory, so this run still acts once. What is lost is the + // guarantee across a restart: an acknowledged state can be offered again and its + // side effects run a second time. Recoverable, unlike the alternative of dropping + // the state, and every later acknowledgement retries the write. + Log.p("Continuity: the delivery marks could not be stored; an acknowledged state " + + "may be offered again after a restart."); + } } catch (Throwable t) { Log.e(t); } @@ -1774,7 +1838,13 @@ private static void rememberSeen() { private static long loadSequence() { try { - return Preferences.get(PREF_SEQUENCE, (long) 0); + if (!Display.isInitialized() || !Storage.getInstance().exists(PREF_SEQUENCE)) { + return 0; + } + Object o = Storage.getInstance().readObject(PREF_SEQUENCE); + // instanceof rather than a cast: a failed cast does not throw on the iOS virtual + // machine, it hands the wrong object to the next instruction. + return o instanceof Number ? ((Number) o).longValue() : 0; } catch (Throwable t) { Log.e(t); return 0; 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 index c9e4519d103..43ffbafbe8a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -65,10 +65,10 @@ public class LocalContinuityTest extends UITestBase { 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); + // The delivery high-water marks are DURABLE by design, so they outlive reset() -- which + // is the whole point of them, and which makes them leak from one test into the next + // unless each starts from a clean slate. clearStorage() above now covers them: they moved + // out of Preferences, which cannot report a failed write, and into Storage, which can. bridge = new LocalContinuityBridge(); Continuity.setBridge(bridge); // A running application has a form on screen, and the framework deliberately holds an @@ -1935,6 +1935,74 @@ public void storeChanged() { "a sync-only client got no callback on the replacement bridge"); } + /** + * A checkpoint must not overwrite the relay's copy of an arrival the user is still deciding + * about. The relay holds one document per user, and a parked state exists ONLY in memory -- + * so publishing replaces the last copy of it that exists anywhere, and a process death while + * the prompt is on screen loses it outright. + */ + @EdtTest + public void aCheckpointWaitsWhileAnArrivalIsParked() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + + // An arrival the application has been asked about and has not answered. + Continuity.deliver(fromElsewhere("waiting on the user", 91L)); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), "the arrival should be parked"); + + Continuity.checkpoint(); + pause(300L); + assertEquals(before, r.sent.size(), + "a checkpoint overwrote the relay's only copy of a state the user was still " + + "being asked about"); + + // Answering releases it -- held, not dropped. + Continuity.restore(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the held publication was dropped rather than sent once the decision was made"); + } + + /** + * A mark that never reached storage must not be reported as durable. Preferences cannot say + * whether a write landed -- set() fills a static table and save() discards + * Storage.writeObject()'s result -- so these values moved to Storage, which can. + */ + @EdtTest + public void marksThatCannotBeStoredAreNotSilentlyClaimed() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingStorage()); + try { + Continuity.acknowledge(fromElsewhere("cannot be stored", 95L)); + } finally { + Storage.setStorageInstance(original); + } + + Map persisted = Continuity.readSeenForTest(); + assertFalse(persisted.containsKey("some-other-device"), + "a mark that never reached storage was reported as durable"); + } + /** * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full @@ -2157,6 +2225,14 @@ void release() { /// out), and an absence asserted too early is not evidence of anything: the publish simply /// had not happened yet. A bare sleep gave exactly that, so the test could pass without /// the code under it ever running. + /** Waits until more than `count` states have been sent. */ + void awaitAnySince(int count) { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline && sent.size() <= count) { + sleepBriefly(); + } + } + void awaitSent(long sequence) { long deadline = System.currentTimeMillis() + 5000L; while (System.currentTimeMillis() < deadline) { From 63d767d098226de6700f08eb0c54885820832901 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:37:40 +0300 Subject: [PATCH 046/140] Continuity: a sequence that is not durable is stored but never published capture() reported two different failures through one flag, and checkpoint() therefore gave them the same treatment: carry the payload forward, keep the checkpoint owed, and publish. Publishing is right for one of them and harmful for the other. A provider that throws leaves the state without its payload. The routes are still current and real, so storing and advertising the state is the best answer available. A sequence that did not reach the disk is the opposite. The local write is still worth doing -- this device does not deduplicate against itself -- but handing that number to another device is the damage: the receiver records it durably, this device hands the same number out again after a restart, and every checkpoint it sends from then on is refused as already seen until the counter climbs past it. Nothing is published now when the counter could not be stored. This is the same conflation twice over. The flag was providerFailed, acquired a second meaning when the sequence check was added, and I renamed it to captureFailed -- which fixed the label and left the behaviour wrong. They are two flags now because they call for two answers, and the javadoc says so. The test needed a Storage that refuses ONE name: the existing one refuses every write, which cannot tell the payload path from the sequence path and would have been a test that could not distinguish what it was checking. It also calls checkpoint() directly rather than routeStackChanged(), which only SCHEDULES one -- the queued checkpoint ran after the finally had restored real storage, so the first version of the test was grading a second, entirely successful publish. Proven by reverting the guard alone. Suite 115/115 twice, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 84 +++++++++---------- .../continuity/LocalContinuityTest.java | 83 ++++++++++++++++++ 2 files changed, 123 insertions(+), 44 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1a942ca8b87..b332578b07e 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -533,49 +533,41 @@ public static void checkpoint() { if (!enabled) { return; } - boolean[] captureFailed = new boolean[1]; - AppState state = capture(captureFailed); + // TWO answers, because the two failures need opposite handling and one flag gave them the + // same. A payload that could not be read still leaves routes worth saving and worth + // advertising; a sequence that did not reach the disk makes PUBLISHING the harmful part, + // because the receiving devices record that number durably and this one will hand it out + // again after a restart -- so every checkpoint it then sends is refused as already seen + // until the counter climbs past it. Folding them together published exactly that. + boolean[] payloadFailed = new boolean[1]; + boolean[] sequenceFailed = new boolean[1]; + AppState state = capture(payloadFailed, sequenceFailed); if (state == null) { return; } - if (captureFailed[0]) { - // The last payload is CARRIED FORWARD rather than replaced by nothing. - // - // A provider that throws leaves this state with no payload, and writing that over a - // stored draft loses it -- the very thing a checkpoint exists to prevent, caused by a - // read that may well succeed next time. Skipping the write instead was the first - // attempt and it traded one loss for another: the rule beside this one is that a - // provider failure costs its own payload and NOTHING MORE, so the routes, which are - // current and real, have to be saved. - // - // Carrying forward gives up neither. The stored checkpoint keeps the newest routes - // and the newest payload that was ever successfully read, which is exactly what the - // application would have written had it checkpointed a moment before the failure. - // - // Found by a test failing only when the whole suite ran: another class had left a - // navigation stack behind, so the state was not empty, and an earlier version of this - // guard -- which only skipped EMPTY states -- wrote routes over the draft. That is - // not a fixture artifact; it is an application with routes whose provider threw. + if (payloadFailed[0]) { + // The last payload is CARRIED FORWARD rather than replaced by nothing. Writing an + // empty state over a stored draft loses it, for a read that may well succeed next + // time; skipping the write instead loses the routes, which are current and real. + // Carrying forward gives up neither -- the checkpoint keeps the newest routes and the + // newest payload that ever read cleanly. AppState previous = readStored(); if (previous != null && !previous.getPayload().isEmpty()) { state.setPayloadUnchecked(previous.getPayload()); } - // Still owed either way, so a later suspend retries the capture that failed. - dirty = true; - persist(state); - publishContinuation(state); - publishToRelay(state); - return; } - dirty = false; + // Owed while anything about this capture was not durable, so a later suspend retries it. + dirty = payloadFailed[0] || sequenceFailed[0]; if (!persist(state)) { - // Still owed. `dirty` was cleared on the way in, and leaving it clear told the next - // suspend there was nothing to write -- so a checkpoint that failed on a full disk - // was never retried, and the app came back to whatever the last successful write - // held. The other channels still run: a continuation and a relay copy that reached - // the user's other devices are worth having even when this one could not be stored. dirty = true; } + if (sequenceFailed[0]) { + // Stored locally and told to nobody. The local copy is still worth having -- this + // device does not deduplicate against itself -- but a sequence that cannot be proved + // durable must not reach another device, because a receiver's mark outlives the + // counter that produced it. + return; + } publishContinuation(state); publishToRelay(state); } @@ -611,16 +603,23 @@ public static AppState capture() { // Best effort, which is what this method has always been: an application calling it to // feed its own transport wants whatever can be gathered. checkpoint() asks the private // form instead, because for the DURABLE path a provider failure is not "no payload". - return capture(new boolean[1]); + return capture(new boolean[1], new boolean[1]); } - /// As above, reporting through `captureFailed` whether anything went wrong gathering the - /// state -- the provider throwing, or the sequence counter failing to reach disk. + /// As above, reporting the two ways a capture can come up short -- SEPARATELY, because the + /// caller has to treat them differently. + /// + /// `payloadFailed` means the provider threw and this state has no payload of its own. The + /// routes are still real and the state is still worth storing and advertising. + /// + /// `sequenceFailed` means the counter did not reach the disk. That one makes publishing the + /// harmful act: a receiver records the sequence durably, this device hands the same number + /// out again after a restart, and every later checkpoint is refused as already seen. /// - /// Named for the QUESTION rather than one of its causes. It began as "providerFailed" and - /// then acquired a second meaning, which is the sort of drift that makes a caller reason - /// about the wrong thing. - private static AppState capture(boolean[] captureFailed) { + /// They were one flag, twice: first called providerFailed and then given a second meaning, + /// then renamed to captureFailed to match -- which papered over the fact that the two answers + /// call for opposite handling rather than a better name. + private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFailed) { if (!enabled) { return null; } @@ -643,7 +642,7 @@ private static AppState capture(boolean[] captureFailed) { // draft that was safely stored a moment earlier, because of a failure that may // well be transient. Log.e(t); - captureFailed[0] = true; + payloadFailed[0] = true; } if (payload != null) { // NOT caught. An unrepresentable value is a programming error with exactly one @@ -661,10 +660,7 @@ private static AppState capture(boolean[] captureFailed) { // relaunch -- so a receiver still holding the old high-water mark in lastSeen silently // ignored every state until the counter caught up. if (!rememberSequence(seq)) { - // Treated exactly like a provider that threw: the state is not durable, so the caller - // keeps the checkpoint owed and does not publish a sequence that this device cannot - // prove it will still be past after a restart. - captureFailed[0] = true; + sequenceFailed[0] = true; } state.setDeviceId(getDeviceId()) .setSequence(seq) 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 index 43ffbafbe8a..deb59f09358 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2060,6 +2060,89 @@ public boolean stateReceived(AppState state) { "a state whose write failed was marked handled, so it can never be recovered"); } + /** + * A sequence that could not be stored must not be handed to another device. The receiver + * records it durably, this device hands the same number out again after a restart, and every + * checkpoint it then sends is refused as already seen until the counter climbs past it. + * + *

The local write still happens: this device does not deduplicate against itself, so the + * stored checkpoint is worth having. Only the PUBLISHING is harmful, which is why the payload + * failure beside it -- where publishing is fine -- cannot share the same flag.

+ */ + @EdtTest + public void aSequenceThatCannotBeStoredIsNotPublished() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + bridge.clearContinuation(); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEQUENCE)); + try { + // NOT routeStackChanged(): that only schedules a flush, and the queued checkpoint + // then ran after the finally below had put the real storage back -- so the test was + // watching a second, perfectly successful checkpoint publish and calling it a + // failure of the first. + Continuity.checkpoint(); + flushSerialCalls(); + } finally { + Storage.setStorageInstance(original); + } + pause(300L); + + assertEquals(before, r.sent.size(), + "a sequence that never reached storage was published to the relay, so the " + + "receiver's durable mark will outlive the counter that produced it"); + assertNull(bridge.getPublishedType(), + "the same sequence was advertised over the platform continuation"); + assertTrue(Continuity.isCheckpointPending(), + "the checkpoint was reported as done even though the counter is not durable"); + } + + /** Storage that refuses ONE name and passes everything else through. */ + static class RefusingOneStorage extends Storage { + private final Storage delegate; + private final String refused; + + RefusingOneStorage(Storage delegate, String refused) { + this.delegate = delegate; + this.refused = refused; + } + + @Override + public boolean writeObject(String name, Object o) { + if (refused.equals(name)) { + return false; + } + return delegate.writeObject(name, o); + } + + @Override + public Object readObject(String name) { + return delegate.readObject(name); + } + + @Override + public boolean exists(String name) { + return delegate.exists(name); + } + + @Override + public void deleteStorageFile(String name) { + delegate.deleteStorageFile(name); + } + } + /** Storage whose writes always fail, which is what a full disk looks like. */ static class RefusingStorage extends Storage { @Override From 79250e53b4bd5f501f8c4fe47fa70e2251ab759c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:04:35 +0300 Subject: [PATCH 047/140] Continuity: release the parked slot on every path that ends an arrival The publication hold added in the previous commit blocks on `parked != null`, and resumption was wired into only the paths I happened to be looking at. Two others end an arrival and both left a checkpoint withheld for good. acknowledge() is the documented way for an application to decline a state it was asked about. It recorded the mark and left the state parked, so getRestorableState() kept offering something already dealt with and the hold kept withholding publications for it. Releasing there is safe precisely because it was acknowledged: the mark is durable, so the relay's copy is no longer the only one. An arrival that expires while parked is discarded by getRestorableState(), which also did not resume. The hold protects the relay's only copy of a LIVE arrival; an expired one will not be restored by anything, so holding a checkpoint behind it just means it never reaches the user's other devices. That is the shape of the mistake rather than two separate slips: a guard on shared state needs every transition out of that state enumerated when the guard is written, not discovered one review round at a time. Separately, ios.plistInject declaring CN1ContinuityActivityType with the wrong plist TYPE now fails the build. topLevelPlistString answers null for an array or a dict, so such a declaration fell through both branches -- the value was left alone because something was declared, and ours was not added because the key was present. The build succeeded and CodenameOne_GLAppDelegate.m treated the non-NSString as absent, so every continuation bypassed the handler on a project that looked configured. Three test defects fixed rather than shipped. The assertions checked for null where getRestorableState() legitimately returns the LOCAL checkpoint, so they failed on correct behaviour; they check identity now. And awaitAnySince waited 5000ms against the harness's own 5000ms limit, so a regression reported "FormTest timed out" instead of the assertion explaining it. Both hold-release fixes proven by reverting each alone. Suite 117/117, plugin 54/54, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 14 +++ .../com/codename1/builders/IPhoneBuilder.java | 11 ++ .../IPhoneBuilderContinuityPlistTest.java | 20 ++++ .../continuity/LocalContinuityTest.java | 100 +++++++++++++++++- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index b332578b07e..a646391e667 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -692,6 +692,11 @@ public static AppState getRestorableState() { // ordinary with automatic restore off and the user still navigating -- so a // single restore() call told the application to show its initial screen instead. parked = null; + // And the checkpoint that was waiting behind it goes out. The hold is there to + // protect the relay's only copy of a live arrival; this one has expired and will + // not be restored by anything, so holding a publication for it forever is just a + // checkpoint that never reaches the user's other devices. + startPublisher(); } else { return waiting; } @@ -1623,6 +1628,15 @@ private static void noteActedOn(AppState state) { recordDurable(from, seq); } } + if (isSameState(parked, state)) { + // The application has dealt with this arrival -- acknowledge() is the documented way + // to decline one -- so the slot must not go on offering it through + // getRestorableState(), and must not go on holding a checkpoint back either. The + // hold exists because a parked state's only copy is on the relay; an acknowledged + // state has a durable mark, so overwriting the relay's copy is now safe. + parked = null; + startPublisher(); + } // ALWAYS, not only when a map moved. The condition this replaced was written when the // durable copy tracked memory exactly; it does not, and by the time anything calls this // memory already holds the entry, so "unchanged" meant "write nothing" and both 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 6c7017d8a2c..942026b64d1 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 @@ -11705,6 +11705,17 @@ static String withContinuityActivityType(String inject, String continuityType) // because this is not a hint. An injected key naming a different type has the delegate // rejecting the application's own continuations while iOS goes on offering them. String injected = topLevelPlistString(inject, "CN1ContinuityActivityType"); + if (injected == null && declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) { + // Declared, but not as a string. topLevelPlistString answers null for an array, a + // dict or anything else, so this used to fall through both branches: the value was + // left alone because something was declared, and ours was not added because the key + // was present. The build then succeeded and the delegate found a value that is not an + // NSString, which it treats as absent -- so every continuation quietly bypassed the + // handler on a build that looked configured. + throw new BuildException("ios.plistInject declares CN1ContinuityActivityType with a " + + "non-string value. It has to be the activity type this build publishes, '" + + continuityType + "', or be left out so the build writes it."); + } if (injected != null && !injected.equals(continuityType)) { throw new BuildException("ios.plistInject sets CN1ContinuityActivityType to '" + injected + "' while this build publishes '" + continuityType 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 index ce74bbc8387..2477818f127 100644 --- 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 @@ -149,6 +149,26 @@ void aDisagreeingInjectedTypeIsRefused() { } } + /** + * A declaration of the wrong plist TYPE is refused rather than accepted. topLevelPlistString + * answers null for an array or a dict, which used to mean the value was left alone (something + * was declared) and ours was not added (the key was present) -- so the build succeeded and + * the delegate found a value that is not an NSString, treated it as absent, and let every + * continuation bypass the handler on a build that looked configured. + */ + @Test + void aNonStringDeclarationIsRefused() { + String arrayValued = "CN1ContinuityActivityType" + + "com.example.app.continuity"; + + try { + IPhoneBuilder.withContinuityActivityType(arrayValued, CONTINUITY_TYPE); + fail("a non-string CN1ContinuityActivityType must not be accepted"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("non-string"), expected.getMessage()); + } + } + // ------------------------------------------------------------------ // Emitting the key // ------------------------------------------------------------------ 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 index deb59f09358..4903d5e12f5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2143,6 +2143,96 @@ public void deleteStorageFile(String name) { } } + /** + * Declining an arrival with acknowledge() must release it. The state stays in the parked slot + * otherwise, so getRestorableState() goes on offering something the application has already + * dealt with -- and the checkpoint hold, which exists to protect a live arrival's only copy, + * goes on withholding publications for a state nobody will ever restore. + */ + @EdtTest + public void acknowledgingAParkedArrivalReleasesItAndTheHeldPublication() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + + AppState arrival = fromElsewhere("the user says no", 101L); + Continuity.deliver(arrival); + flushSerialCalls(); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), "the checkpoint should be held while it is parked"); + + Continuity.acknowledge(arrival); + + // Identity, not null. getRestorableState() falls back to the LOCAL checkpoint when + // nothing is parked, and this test just wrote one -- so asserting null here fails on + // correct behaviour. What must not come back is the arrival that was acknowledged. + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "some-other-device".equals(left.getDeviceId()), + "an acknowledged arrival is still being offered for restoration"); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held behind a state the application had acknowledged"); + } + + /** + * And an arrival that expires while parked releases the hold too. The hold protects the + * relay's only copy of a LIVE arrival; an expired one will never be restored by anything, so + * holding a checkpoint behind it just means it never reaches the user's other devices. + */ + @EdtTest + public void anExpiredParkedArrivalReleasesTheHeldPublication() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + + Continuity.parkForTest(fromElsewhere("goes stale while waiting", 103L)); + Continuity.setMaxAge(1L); + Continuity.checkpoint(); + pause(250L); + + // Asking is what discards the expired arrival, and the hold has to go with it. Checked + // by identity for the reason the sibling above gives: a local checkpoint is a legitimate + // answer here, the expired arrival is not. + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "some-other-device".equals(left.getDeviceId()), + "an expired parked state was still offered"); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held behind an arrival that had already expired"); + Continuity.setMaxAge(0L); + } + /** Storage whose writes always fail, which is what a full disk looks like. */ static class RefusingStorage extends Storage { @Override @@ -2308,9 +2398,15 @@ void release() { /// out), and an absence asserted too early is not evidence of anything: the publish simply /// had not happened yet. A bare sleep gave exactly that, so the test could pass without /// the code under it ever running. - /** Waits until more than `count` states have been sent. */ + /** + * Waits until more than `count` states have been sent. + * + * Deliberately shorter than the harness's own 5s limit. At 5000 a regression raced it and + * the test reported "FormTest timed out" instead of the assertion that explains what + * broke -- a failure message that sends the next reader hunting a hung event thread. + */ void awaitAnySince(int count) { - long deadline = System.currentTimeMillis() + 5000L; + long deadline = System.currentTimeMillis() + 2500L; while (System.currentTimeMillis() < deadline && sent.size() <= count) { sleepBriefly(); } From 84f95325a70d76471591d67821acc33304266db7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:47:47 +0300 Subject: [PATCH 048/140] LocalCallTest: the timer check asserts what its message claims deferringTwiceLeavesNoTimerThreadBehind failed PR CI on this branch with "expected: <4> but was: <2>" -- two FEWER timer threads than the baseline, which is the opposite of the leak it exists to catch. liveTimerThreads() counts every thread in the JVM named "Timer-", so the baseline includes timers started by whatever ran earlier in the same module. The wait loop above spins only while the count is GREATER than that baseline, so it exits immediately once an unrelated timer expires, and assertEquals then compares a count that has legitimately dropped. Nothing about call deferral is involved. The property in the message -- "answering must leave no safety timer running" -- is that the count has not GROWN. It is <= rather than ==, which is immune to other tests' timers finishing and still fails on the leak it was written for. Pre-existing on master since "First-class call management, VoIP and VPN" (#5604) and untouched by this branch. It surfaced here because this branch adds tests to the same module, which changes what else is in flight -- the continuity workers are named "Continuity relay publish", "Continuity relay poll" and "Continuity window wait", so they never entered this count. Verified by running the whole module the way CI does rather than the class alone: LocalCallTest on its own reports 63 errors of "Crypto Codename One is not initialised", because it depends on module-wide initialisation -- a filtered run is not evidence either way. Full module: 6234/6234, the same count CI reported with its one failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/java/com/codename1/call/LocalCallTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java index b11cf898e8e..1a8d47af728 100644 --- a/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java @@ -707,7 +707,15 @@ public void endRequested(String callId, CallAction action) { && System.currentTimeMillis() < limit) { sleep(); } - assertEquals(before, liveTimerThreads(), + // NOT MORE than before, rather than exactly the same. What this test is about is a + // safety timer that outlives the answer, so growth is the failure; a DROP is not. The + // count is JVM-wide -- liveTimerThreads() matches any thread named "Timer-" -- so timers + // started by earlier tests in this run can expire inside the window above, and the loop + // exits immediately when that happens because the count is no longer greater than the + // baseline. Equality then failed with "expected 4 but was 2": two unrelated timers had + // finished, which is exactly what should happen and has nothing to do with call + // deferral. + assertTrue(liveTimerThreads() <= before, "answering must leave no safety timer running"); } From 21ba45a5abe94865f952bda692803bca68b19010 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:13:20 +0300 Subject: [PATCH 049/140] Continuity: every reader of the two mark maps handles them disagreeing Admission consulted only lastSeen. The two maps hold different sets -- lastSeen takes every arrival, durableSeen only the ones that completed -- and they are bounded independently, so they evict at different rates and an origin can survive in one after being dropped from the other. Acknowledge a state, admit more than MAX_SEEN other origins, and the acknowledged one is gone from lastSeen while its durable mark remains: the duplicate then passed the check and ran the application's listeners a second time, against the act-once guarantee that mark exists to give. Admission takes the higher of the two now. enable() had the same divergence and is fixed here without being reported. It decided BOTH maps on a single lastSeen comparison, so a loaded mark -- which by definition describes a state a previous run COMPLETED -- was skipped entirely when the in-memory set already held something newer from this run. The durable set is the one that survives the next restart, so it is the one that must not lose it. The two are advanced independently now. That second fix came from auditing every read of both maps rather than waiting for the next round. Three findings in a row traced to one change -- splitting a single map into two that can disagree -- and each time the reported site was fixed alone. The audit also confirms one reader that is correct as it stands: admit()'s second-turn supersession check asks whether something newer has been ADMITTED since, which is what lastSeen records and durableSeen does not. Full module 6235/6235, the dual-map check proven by reverting it. SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 44 ++++++++++++++++--- .../continuity/LocalContinuityTest.java | 39 ++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index a646391e667..ee688aa1558 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -250,11 +250,22 @@ public static void enable() { // as new as the stored one. Map restored = readSeen(); for (Map.Entry e : restored.entrySet()) { - Long have = lastSeen.get(e.getKey()); - if (have == null || have.longValue() < e.getValue().longValue()) { - // Loaded marks describe states a previous run COMPLETED, so they are durable - // again as well as suppressing re-delivery in this one. - recordSeen(e.getKey(), e.getValue().longValue(), true); + // The two maps are advanced INDEPENDENTLY, because they can disagree and gating one + // on the other loses a mark. A loaded value describes a state a previous run + // COMPLETED, so it belongs in the durable set even when the in-memory set already + // holds something newer from this run -- deciding both on the lastSeen comparison + // alone dropped it, and the durable set is the one that survives the next restart. + // + // Found by auditing the read sites after a review found the same divergence at + // admission, rather than waiting for it to be reported. + long loaded = e.getValue().longValue(); + Long inMemory = lastSeen.get(e.getKey()); + if (inMemory == null || inMemory.longValue() < loaded) { + recordSeen(e.getKey(), loaded, false); + } + Long durable = durableSeen.get(e.getKey()); + if (durable == null || durable.longValue() < loaded) { + recordDurable(e.getKey(), loaded); } } enabled = true; @@ -1405,7 +1416,7 @@ private static void admit(final AppState state) { // fresher state from the same device. return; } - Long seen = lastSeen.get(state.getDeviceId()); + Long seen = seenSequence(state.getDeviceId()); if (seen != null && seen.longValue() >= state.getSequence()) { // Delivered twice, which happens routinely: a continuation and a relay poll can carry // the same state. @@ -1683,6 +1694,27 @@ private static void recordSeen(String device, long sequence, boolean durable) { trimSeen(); } + /// The highest sequence known for a device, from EITHER map. + /// + /// The two are bounded independently and hold different sets -- lastSeen takes every arrival, + /// durableSeen only the ones that completed -- so they evict at different rates and an origin + /// can survive in one after being dropped from the other. Asking only lastSeen therefore let + /// a duplicate through: acknowledge a state, admit more than MAX_SEEN other origins, and the + /// acknowledged one is evicted from lastSeen while its durable mark remains. The duplicate + /// then passed the check and ran the application's listeners a second time, against the + /// act-once guarantee the durable mark exists to give. + private static Long seenSequence(String device) { + Long inMemory = lastSeen.get(device); + Long durable = durableSeen.get(device); + if (inMemory == null) { + return durable; + } + if (durable == null) { + return inMemory; + } + return durable.longValue() > inMemory.longValue() ? durable : inMemory; + } + /// Marks a device durably without disturbing the in-memory dedup mark, which may be newer. private static void recordDurable(String device, long sequence) { durableSeen.remove(device); 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 index 4903d5e12f5..a8967464325 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2233,6 +2233,45 @@ public void run() { Continuity.setMaxAge(0L); } + /** + * An acknowledged origin stays refused even after the in-memory map has evicted it. lastSeen + * and durableSeen are bounded independently and hold different sets -- every arrival versus + * only the completed ones -- so a busy relay can push an acknowledged origin out of lastSeen + * while its durable mark remains. Consulting only lastSeen let the duplicate through and ran + * the application's listeners a second time, which is exactly what the durable mark is for. + */ + @EdtTest + public void anAcknowledgedOriginIsRefusedAfterItsDedupEntryIsEvicted() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState handled = fromElsewhere("dealt with", 5L); + Continuity.acknowledge(handled); + + // Enough other origins to push it out of the in-memory map, which is capped at 64. + for (int i = 0; i < 90; i++) { + Continuity.deliver(new AppState() + .setDeviceId("crowd-" + i) + .setSequence(i + 1) + .setTimestamp(System.currentTimeMillis())); + } + flushSerialCalls(); + + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + Continuity.deliver(handled); + flushSerialCalls(); + + assertEquals(0, seen[0], + "a state from an acknowledged origin was delivered again once the in-memory " + + "dedup entry had been evicted, so its side effects run twice"); + } + /** Storage whose writes always fail, which is what a full disk looks like. */ static class RefusingStorage extends Storage { @Override From 29b0c08a3e4b968c75d4620522a931878a9a2ceb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:37:22 +0300 Subject: [PATCH 050/140] Continuity: a tombstone ends work still parked from the same origin An empty state is that device saying it has nothing any more. admit() consumed it correctly but left an older state from the same origin sitting in the parked slot, so getRestorableState() went on offering work the origin had explicitly cleared, and the publication hold went on withholding this device's checkpoints behind it. The tombstone now clears a parked state from the same origin whose sequence it meets or exceeds, and resumes the publisher. This is the third path of exactly this kind -- after acknowledge() and expiry -- so the transitions are enumerated here rather than waited for. There are eight writes to the parked slot. Five clear it and all five now resume the publisher: restore(), windowWaitFinished(), noteActedOn(), the expiry branch in getRestorableState(), and this one. The other three provably need nothing: disable() leaves `enabled` false and startPublisher() requires it, clear() has already dropped the pending publication through endRelaySession(), and reset() is a test seam that resets the flags outright. The guard that made those transitions matter went in three commits ago. The enumeration takes a minute and would have found all five then; three review rounds found three of them one at a time instead. Full module 6236/6236, the fix proven by reverting it. SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 13 +++++ .../continuity/LocalContinuityTest.java | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index ee688aa1558..cf0748a8300 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1436,6 +1436,19 @@ private static void admit(final AppState state) { // activity for an empty state rather than advertising one -- and only the relay path // was missing the other half of it. Marked as seen above, so it is consumed rather // than reconsidered, and simply not dispatched. + // + // It also SUPERSEDES anything still parked from the same origin. A tombstone is that + // origin saying it has nothing any more, so an older state of its own that is waiting + // on the user is work that no longer exists: getRestorableState() would go on offering + // it, and the publication hold would go on withholding this device's checkpoints + // behind it. Same shape as acknowledge() and expiry -- another way an arrival ends, + // and every one of them has to release the slot. + AppState waiting = parked; + if (waiting != null && state.getDeviceId().equals(waiting.getDeviceId()) + && waiting.getSequence() <= state.getSequence()) { + parked = null; + startPublisher(); + } return; } Display.getInstance().callSerially(new Runnable() { 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 index a8967464325..27fc3b4ba47 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2272,6 +2272,54 @@ public boolean stateReceived(AppState state) { + "dedup entry had been evicted, so its side effects run twice"); } + /** + * A tombstone supersedes work still parked from the same origin. An empty state is that + * device saying it has nothing any more, so an older state of its own that is waiting on the + * user no longer exists -- offering it keeps proposing work the origin cleared, and the + * publication hold keeps this device's checkpoints off the relay behind it. + */ + @EdtTest + public void aTombstoneClearsWorkStillParkedFromTheSameOrigin() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + + Continuity.deliver(fromElsewhere("waiting on the user", 111L)); + flushSerialCalls(); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), "the checkpoint should be held while it is parked"); + + // The same origin says it has nothing now. + Continuity.deliver(new AppState() + .setDeviceId("some-other-device") + .setSequence(112L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "some-other-device".equals(left.getDeviceId()), + "work the origin cleared with a tombstone is still being offered"); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held behind work the origin had already cleared"); + } + /** Storage whose writes always fail, which is what a full disk looks like. */ static class RefusingStorage extends Storage { @Override From a3889dd6e609d917cd11a5a7157b5eca3c69fe04 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:05:00 +0300 Subject: [PATCH 051/140] Continuity: a superseded relay worker never reaches the network A publish worker checked its session only in publishFinished(), which is after the request. clear() or setRelay() landing between the worker being started and its first instruction still let the request go out -- and RestStateRelay resolves getToken() INSIDE publish(), so a logout followed quickly by a login sent the PREVIOUS account's state under the NEXT account's credentials. clear() documents that nothing follows it; this is the one path where something did. The worker now confirms its session on the event thread immediately before the request. callSeriallyAndWait rather than reading relaySession from the worker: that field is owned by the event thread and the worker is not it, and blocking a worker on the EDT is the safe direction because the EDT never waits on a worker. What remains is the instant between the answer and the call, which is the window clear() already documents as unrecallable -- the rest of the gap, between queueing and sending, is closed. A failed fetch no longer authorises a publish either. The exception was collapsed into the same null as "the endpoint has nothing", so pollFinished() believed a read had succeeded and started the publisher -- and the whole reason writing over the relay's single document is safe after a poll is that the poll established what was there. A timeout establishes nothing, so anything owed now waits for a read that succeeds. The logout test is deterministic rather than timed: the worker's confirmation runs ON the event thread, and the test body is the event thread, so it cannot run until after clear() returns. Both proven by reverting each alone -- the session one by removing the guard while leaving the round trip in place, so it reproduces "checked and ignored" rather than changing the timing. Full module 6238/6238, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 49 ++++++++++- .../continuity/LocalContinuityTest.java | 83 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index cf0748a8300..94fda2ad129 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -933,16 +933,24 @@ public void run() { // Off the EDT because fetch() blocks, and touching NOTHING: the relay came in as // a local and the answer goes back through the event queue. AppState fetched = null; + boolean failed = false; try { fetched = r.fetch(); } catch (Throwable t) { + // Kept SEPARATE from "the endpoint had nothing". Collapsing a timeout or a + // server error into the same null told pollFinished() the read had succeeded + // and found an empty relay, which is what makes writing over the document + // safe -- so a queued checkpoint went out and replaced another device's state + // that this device had never managed to read. Log.e(t); + failed = true; } final AppState result = fetched; + final boolean fetchFailed = failed; Display.getInstance().callSerially(new Runnable() { @Override public void run() { - pollFinished(result, session); + pollFinished(result, fetchFailed, session); } }); } @@ -950,7 +958,7 @@ public void run() { } /// A fetch has come back. On the EDT, where every field below is owned. - private static void pollFinished(AppState fetched, int session) { + private static void pollFinished(AppState fetched, boolean fetchFailed, int session) { if (session != relaySession) { // A clear(), a setRelay() or a reset() happened while this was in flight. The answer // belongs to the endpoint or the account that has since gone away: delivering it would @@ -967,6 +975,13 @@ private static void pollFinished(AppState fetched, int session) { startPoll(); return; } + if (fetchFailed) { + // No publication on the strength of a read that did not happen. Sending the queued + // checkpoint would replace the relay's single document, and the whole reason that is + // safe after a poll is that the poll established what was there. A failed fetch + // establishes nothing, so anything owed waits for a read that succeeds. + return; + } // Owed work goes out AFTER the fetch, never before it. startPublisher(); } @@ -1287,6 +1302,36 @@ private static void startPublisher() { Display.getInstance().startThread(new Runnable() { @Override public void run() { + // Confirmed on the EVENT THREAD immediately before the request, not only when it + // comes back. The session check used to live in publishFinished() alone, which is + // after the fact: clear() or setRelay() landing between this worker being started + // and its first instruction still let the request go out. RestStateRelay resolves + // getToken() INSIDE publish(), so a quick logout and login sent the previous + // account's state under the NEXT account's credentials -- while clear() documents + // that nothing follows it. + // + // callSeriallyAndWait, not a read of relaySession from here: that field is owned + // by the event thread and this is not it. Blocking this worker on the EDT is + // fine, it is the direction that is safe -- the EDT never waits on us. + // + // What this cannot close is the instant between the answer and the call below. + // That is the same window clear() already documents: a request on the wire cannot + // be recalled. It closes the rest of it, which was the whole gap between queueing + // and sending. + final boolean[] stillOurs = new boolean[1]; + try { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + stillOurs[0] = session == relaySession; + } + }); + } catch (Throwable t) { + Log.e(t); + } + if (!stillOurs[0]) { + return; + } // Off the EDT because publish() blocks, and touching NOTHING: the relay and the // state came in as locals and the outcome goes back through the event queue. boolean sent = true; 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 index 27fc3b4ba47..948fd08db39 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2320,6 +2320,89 @@ public void run() { "the checkpoint stayed held behind work the origin had already cleared"); } + /** + * A logout between queueing a publish and the worker reaching the network must stop the + * request. RestStateRelay resolves getToken() INSIDE publish(), so a quick logout and login + * would otherwise send the previous account's state under the next account's credentials -- + * and clear() documents that nothing follows it. + */ + @EdtTest + public void aLogoutStopsAPublishThatHasNotReachedTheNetwork() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + + // Queue a publish, then sign out before the worker can get to the wire. The worker + // confirms its session on the event thread, and this test body IS the event thread, so + // the confirmation cannot run until after clear() below. + Continuity.checkpoint(); + final int before = r.sent.size(); + Continuity.clear(); + pause(500L); + + assertEquals(before, r.sent.size(), + "a state queued before the logout was sent afterwards, which with a token " + + "resolved inside publish() means the previous account's work went out " + + "under the next account's credentials"); + } + + /** + * A fetch that failed is not an empty relay. Collapsing a timeout into the same null said the + * read had succeeded and found nothing -- which is what makes overwriting the single document + * safe -- so a queued checkpoint replaced another device's state this one had never read. + */ + @EdtTest + public void aFailedFetchDoesNotAuthoriseAPublish() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final ThrowingFetchRelay r = new ThrowingFetchRelay(); + Continuity.setRelay(r); + + Continuity.checkpoint(); + Continuity.pollRelay(); + pause(600L); + + assertTrue(r.fetches() > 0, "the relay should have been asked"); + assertEquals(0, r.published(), + "a checkpoint was published on the strength of a fetch that failed, so another " + + "device's state can be overwritten without ever having been read"); + } + + /** A relay whose fetch always fails, which is what a timeout looks like. */ + static class ThrowingFetchRelay implements StateRelay { + private final java.util.concurrent.atomic.AtomicInteger fetched = + new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicInteger posts = + new java.util.concurrent.atomic.AtomicInteger(); + + public void publish(AppState state) { + posts.incrementAndGet(); + } + + public AppState fetch() throws java.io.IOException { + fetched.incrementAndGet(); + throw new java.io.IOException("the endpoint timed out"); + } + + int fetches() { + return fetched.get(); + } + + int published() { + return posts.get(); + } + } + /** Storage whose writes always fail, which is what a full disk looks like. */ static class RefusingStorage extends Storage { @Override From 02a882ecb7a762033814354d30f0475842169496 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:31:34 +0300 Subject: [PATCH 052/140] Continuity: capture() refuses a state whose sequence never reached storage checkpoint() already answers a failed sequence write by storing locally and staying pending rather than publishing, because publishing is the harmful act: a receiver records the number against its durable high-water mark, this device reloads a LOWER counter after a restart and issues the same number again, and every state it sends from then on is discarded as already seen -- silently, on both sides, until the counter climbs past it. The public capture() discarded that answer and handed the state over anyway. It is documented for feeding the application's own transport, so its caller IS the publisher, and it cannot make the judgement itself: nothing on AppState says whether its number is one this device will hand out again. Best effort still holds for the PAYLOAD -- a provider that threw leaves real routes worth sending -- which is why the two failures have needed separate flags since they were split. This is the sequence half finally being read by the caller that publishes. The test asserts the control first: with working storage the same call must produce a state, or an unconditional null would satisfy the refusal and break the method. Proven by reverting the guard alone, which returns AppState{routes=0, payload=1, seq=2} where null is required. Full module 6239/6239, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright (against the merge base), control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 31 ++++++++++++---- .../continuity/LocalContinuityTest.java | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 94fda2ad129..c6961aafeda 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -601,20 +601,39 @@ public static boolean isCheckpointPending() { /// /// 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. + /// application that never checkpoints. When that counter cannot be written this returns null, + /// because a state carrying a number this device will hand out again is unsafe to send. /// /// #### Returns /// - /// the current state, or null when the framework is not enabled + /// the current state, or null when the framework is not enabled or the sequence counter + /// could not be stored -- see below for why the second one is refused rather than returned /// /// #### Throws /// /// - `IllegalArgumentException`: when the provider returned an unrepresentable payload public static AppState capture() { - // Best effort, which is what this method has always been: an application calling it to - // feed its own transport wants whatever can be gathered. checkpoint() asks the private - // form instead, because for the DURABLE path a provider failure is not "no payload". - return capture(new boolean[1], new boolean[1]); + boolean[] payloadFailed = new boolean[1]; + boolean[] sequenceFailed = new boolean[1]; + AppState state = capture(payloadFailed, sequenceFailed); + if (sequenceFailed[0]) { + // Refused rather than returned. The one documented use of this method is to hand the + // state to a transport of the application's own, and that is exactly the act a + // non-durable sequence makes harmful: the receiver records the number against its + // durable high-water mark, this device reloads a LOWER counter after a restart and + // issues the same number again, and every state it sends from then on is discarded + // as already seen -- silently, on both sides, until the counter climbs past it. + // + // checkpoint() answers the same failure by storing locally and staying pending, and + // this is the same rule for the path where the caller IS the publisher: it cannot + // make that judgement, because nothing on the state says the number is safe. + // + // Best effort still holds for the PAYLOAD -- a provider that threw leaves real routes + // worth sending, which is why the two failures cannot share a flag. rememberSequence() + // has already logged the storage failure, so the null is not the only trace. + return null; + } + return state; } /// As above, reporting the two ways a capture can come up short -- SEPARATELY, because the 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 index 948fd08db39..b42d2a7287b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2109,6 +2109,42 @@ public void run() { "the checkpoint was reported as done even though the counter is not durable"); } + /** + * The public capture() is documented for feeding the application's own transport, so it is a + * publisher too -- and a state whose sequence never reached storage is exactly what must not + * be published. The caller cannot tell: nothing on AppState says whether its number is one + * this device will issue again after a restart. + * + *

The control half matters more than the failing half here. Returning null unconditionally + * would satisfy the assertion below while breaking the method, so the same call is made with + * working storage first and required to produce a state.

+ */ + @EdtTest + public void aCaptureWhoseSequenceCannotBeStoredIsRefused() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + + AppState healthy = Continuity.capture(); + assertNotNull(healthy, + "capture() returned nothing with storage working, so the refusal below proves " + + "nothing about the sequence"); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEQUENCE)); + AppState refused; + try { + refused = Continuity.capture(); + } finally { + Storage.setStorageInstance(original); + } + + assertNull(refused, + "capture() handed out a state whose sequence never reached storage, and the " + + "caller publishes it -- so the receiver's durable mark outlives the " + + "counter that produced it and later states are silently ignored"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 09d9d984ea6b15725f5c13f37027d8542d25edd8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:33:09 +0300 Subject: [PATCH 053/140] Continuity: the marks stay writable, and a type with nowhere to go fails the build Two silent failures, one on each side of the feature. The high-water marks go to storage as ONE string, written as modified UTF-8 with a length that stops at 65535 bytes. MAX_SEEN bounds the COUNT, which is not that bound: a device id is checked against the 65535 limit one at a time on its way into an AppState, and ids arrive from OTHER devices, so a single maximum-length one already overflows the combined string and a few merely large ones do it together. The write then fails every time, quietly. This run still acknowledges correctly from memory and nothing reaches the disk, so after every restart the relay offers an already-applied state again and its side effects run a second time. The probe shows it exactly: with the count cap alone the stored map freezes at 16 entries and every later acknowledgement is lost. So the cap is a byte budget as well as a count, applied where entries go IN -- the same place, the same victims, and the same trade the count cap already makes: the eldest go, and losing a mark costs one duplicate delivery rather than the durability of all of them. StateCodec grows the exact modified-UTF-8 counter it already had inside exceedsWritableLength. The builder half: an ios.plistInject that declares NSUserActivityTypes with a value that is not an array left the continuity type in no array at all. The caller sees the key and writes no array of its own, and the merge's documented answer for "no array here" is to return the fragment untouched -- which is right on its own, since a SECOND NSUserActivityTypes key is a plist iOS reads unpredictably. Together they meant the build succeeded, CN1ContinuityActivityType was present, and Handoff was never advertised. Refused now, but only when a continuity type depends on that array. The declaration is malformed either way -- iOS requires an array -- so an intents-only project keeps the behaviour it has today; failing those builds is not this feature's change to make. It is the same rule withContinuityActivityType already applies one key over. Two existing tests asserted the old silence with a continuity type present. They are not deleted: their real subject -- that a non-array value is never edited and never borrows a LATER key's array -- is asked without a continuity type, where it can still be observed, and the refusal has tests of its own beside them. The long-id test needed three attempts to become honest. It first delivered bare states and asserted on a file nothing had written; that failed identically with SHORT ids, which is what showed the fixture was wrong rather than the code. Full module 6240/6240, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 41 ++++++ .../com/codename1/continuity/StateCodec.java | 17 ++- .../com/codename1/builders/IPhoneBuilder.java | 33 ++++- .../IPhoneBuilderContinuityPlistTest.java | 122 ++++++++++++++---- .../continuity/LocalContinuityTest.java | 49 +++++++ 5 files changed, 235 insertions(+), 27 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index c6961aafeda..755122a39fe 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1811,6 +1811,47 @@ private static void recordDurable(String device, long sequence) { private static void trimSeen() { trimTo(durableSeen); trimTo(lastSeen); + // durableSeen only: lastSeen is never written anywhere, so no byte budget applies to it. + trimToWritable(durableSeen); + } + + /// Evicts the eldest durable marks until the whole map still fits in one stored string. + /// + /// MAX_SEEN bounds the COUNT, which is not the same bound. The marks go to storage as a single + /// string, and a stored string is written as modified UTF-8 with a length that stops at + /// 65535 bytes -- while a device id is only checked against that limit ONE AT A TIME, on its + /// way into an AppState. Ids arrive from other devices, so a single maximum-length one already + /// makes the combined string too long on its own, and a few merely large ones do it together. + /// + /// The write then fails every time, and the failure is the quiet kind: this run still + /// acknowledges correctly from memory, and nothing is durable, so after every restart the + /// relay can offer an already-applied state again and its side effects run a second time. + /// + /// Evicting is the same trade the count cap already makes, and the same victims: the eldest + /// go, and losing a mark costs one duplicate delivery rather than the durability of all of + /// them. An id so long that it does not fit beside anything is evicted by the same loop. + private static void trimToWritable(Map map) { + int total = 0; + for (Map.Entry e : map.entrySet()) { + total += seenEntryLength(e.getKey(), e.getValue().longValue()); + } + Iterator> i = map.entrySet().iterator(); + while (total > StateCodec.MAX_STRING_BYTES && i.hasNext()) { + Map.Entry e = i.next(); + total -= seenEntryLength(e.getKey(), e.getValue().longValue()); + i.remove(); + } + } + + /// What one mark costs in the stored string, separators included. + /// + /// The trailing separator is counted for every entry including the last, which over-counts by + /// one byte. That is the safe direction for a budget and it keeps the sum independent of + /// which entry happens to be last, so removing one from the front never invalidates the rest. + private static int seenEntryLength(String device, long sequence) { + return StateCodec.writableLength(escapeSeenKey(device)) + + StateCodec.writableLength(Long.toString(sequence)) + + 2; } /// Evicts the least recently seen entries from one map until it is inside MAX_SEEN. diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 1d8881e3b89..7691bcb6074 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -422,6 +422,19 @@ static void requireWritable(String value, String path) { /// at the limit, so a huge string costs the limit rather than its own length, and the running /// total cannot overflow. static boolean exceedsWritableLength(String s) { + return writableLength(s) > MAX_STRING_BYTES; + } + + /// The number of bytes `s` occupies in the modified UTF-8 a stored string is written as. + /// + /// Counted rather than approximated from `length()`, because the limit is on BYTES and a + /// string of accented or CJK characters reaches it at a third of the character count. + /// + /// Stops counting once past the limit, so a huge string costs the limit rather than its own + /// length and the running total cannot overflow. Callers may therefore read the answer as + /// "this many bytes, or more than the limit" -- which is all either of them needs, one to + /// refuse the string and the other to budget for it. + static int writableLength(String s) { int len = 0; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); @@ -433,10 +446,10 @@ static boolean exceedsWritableLength(String s) { len += 2; } if (len > MAX_STRING_BYTES) { - return true; + return len; } } - return false; + return len; } private static void check(Object value, String path, int depth) { 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 942026b64d1..0e62c41d7d6 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 @@ -12005,10 +12005,37 @@ static boolean listsLiveString(String arrayText, String value) { return plistIndexOfLive(arrayText, "" + value + "", 0) >= 0; } - static String mergeUserActivityTypes(String inject, List> intents) { + static String mergeUserActivityTypes(String inject, List> intents) + throws BuildException { return mergeUserActivityTypes(inject, intents, null); } + /// Refuses a build whose continuity type has nowhere to go. + /// + /// Returning the fragment untouched is the right answer for the intents-only merge -- writing + /// a SECOND NSUserActivityTypes key produces a plist iOS reads unpredictably, which is worse + /// than the ids being absent -- but it is the wrong answer once a continuity type depends on + /// that array. The caller has already seen the key and so writes no array of its own, so the + /// type reaches no array at all: the build succeeds, CN1ContinuityActivityType is present, + /// and Handoff is never advertised, with nothing anywhere saying so. + /// + /// The declaration is malformed either way -- iOS requires an array here -- so this is not + /// this build's failure to report in general, and an intents-only project keeps the behaviour + /// it has today. It is reported when continuity depends on it, for the same reason + /// withContinuityActivityType refuses a CN1ContinuityActivityType that is not a string: a + /// feature that is silently inert on the device is the one outcome worth failing a build for. + private static void requireArrayForContinuity(String continuityType, String what) + throws BuildException { + if (continuityType == null || continuityType.length() == 0) { + return; + } + throw new BuildException("ios.plistInject declares NSUserActivityTypes with a value that " + + what + ". This build publishes the continuity activity type '" + continuityType + + "', which iOS only reads from an under that key. Declare it as an " + + "array -- the build adds the type to an array it can find -- or leave the key " + + "out so the build writes the whole array itself."); + } + /// The same merge, adding the continuity activity type alongside the intent ids. /// /// #### Parameters @@ -12017,7 +12044,7 @@ static String mergeUserActivityTypes(String inject, List> in /// - `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) { + String continuityType) throws BuildException { // The same structural reading the rest of the plist parsing uses: this walks a // fragment the application supplied, so "" and "" are shapes it // has to accept. Found by enumerating every literal closing tag left in this @@ -12034,10 +12061,12 @@ static String mergeUserActivityTypes(String inject, List> in // documented behaviour for "no array here" is to return the fragment untouched. int open = immediateValueIndex(inject, key); if (open < 0 || !inject.startsWith("The caller has already seen the key, so it writes no array of its own, and the merge's + * documented answer for "no array here" is to return the fragment untouched -- which is right + * for the intents-only merge, because a SECOND NSUserActivityTypes key is a plist iOS reads + * unpredictably. Together they mean the continuity type reaches no array at all: the build + * succeeds, CN1ContinuityActivityType is present, and Handoff is never advertised.

+ */ + @Test + void aNonArrayActivityTypesDeclarationIsRefusedWhenContinuityNeedsIt() throws BuildException { + String inject = "NSUserActivityTypescom.example.app.other"; + + try { + IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + fail("a NSUserActivityTypes that is not an array must not be silently accepted"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("NSUserActivityTypes"), + expected.getMessage()); + assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage()); + } + } + + /** + * An array that is opened and never closed is refused for the same reason: the merge cannot + * find where to insert, so it returns the fragment untouched and the type goes nowhere. + */ + @Test + void anUnclosedActivityTypesArrayIsRefusedWhenContinuityNeedsIt() throws BuildException { + String inject = "NSUserActivityTypesa"; + + try { + IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + fail("an unterminated NSUserActivityTypes array must not be silently accepted"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("never closed"), expected.getMessage()); + } + } + + /** + * The refusal is scoped to builds that need the array. An intents-only project with the same + * malformed declaration keeps the behaviour it has today: its plist is wrong either way -- iOS + * requires an array here -- and failing those builds is not this feature's change to make. + * + *

This is the half that keeps the two tests above honest. A refusal that fired + * unconditionally would satisfy both of them and break every existing project.

+ */ + @Test + void theSameDeclarationIsLeftAloneWhenNoContinuityTypeNeedsIt() throws BuildException { + String inject = "NSUserActivityTypescom.example.app.other"; + + assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null)); + assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents())); + } + // ------------------------------------------------------------------ // Emitting the key // ------------------------------------------------------------------ @@ -234,7 +290,7 @@ void strippingCommentsFirstWouldHideALiveKeyAfterCdata() { * not advertised -- the same failure the commented-out KEY case has one level up. */ @Test - void aCommentedOutEntryDoesNotSuppressTheType() { + void aCommentedOutEntryDoesNotSuppressTheType() throws BuildException { String inject = "NSUserActivityTypes" + "" + "com.example.app.other"; @@ -252,7 +308,7 @@ void aCommentedOutEntryDoesNotSuppressTheType() { * the merge decided the value was not an array and dropped every activity type. */ @Test - void aProcessingInstructionBetweenKeyAndArrayIsSteppedOver() { + void aProcessingInstructionBetweenKeyAndArrayIsSteppedOver() throws BuildException { String inject = "NSUserActivityTypes"; String merged = IPhoneBuilder.mergeUserActivityTypes( @@ -333,7 +389,7 @@ void theRootDeclarationIsFoundPastANestedOne() { /** The merge follows the same rule, or it rewrites an array the detection branch ignored. */ @Test - void theMergeTargetsTheRootArrayNotANestedOne() { + void theMergeTargetsTheRootArrayNotANestedOne() throws BuildException { String both = "MyFeature" + "NSUserActivityTypes" + "com.example.app.nested" @@ -364,7 +420,7 @@ void aSelfClosingDictDoesNotOpenANestingLevel() { // ------------------------------------------------------------------ @Test - void continuityMergesIntoAnArrayTheApplicationDeclared() { + void continuityMergesIntoAnArrayTheApplicationDeclared() throws BuildException { String inject = "NSUserActivityTypes" + "com.example.app.legacyHandoff"; @@ -376,7 +432,7 @@ void continuityMergesIntoAnArrayTheApplicationDeclared() { } @Test - void intentsAndContinuityBothMergeIntoOneSuppliedArray() { + void intentsAndContinuityBothMergeIntoOneSuppliedArray() throws BuildException { String inject = "NSUserActivityTypes" + "com.example.app.legacyHandoff"; @@ -394,7 +450,7 @@ void intentsAndContinuityBothMergeIntoOneSuppliedArray() { * A project that already named the continuity type itself gets it once, not twice. */ @Test - void anAlreadyDeclaredContinuityTypeIsNotDuplicated() { + void anAlreadyDeclaredContinuityTypeIsNotDuplicated() throws BuildException { String inject = "NSUserActivityTypes" + "" + CONTINUITY_TYPE + ""; @@ -403,19 +459,27 @@ void anAlreadyDeclaredContinuityTypeIsNotDuplicated() { assertEquals(1, occurrences(merged, "" + CONTINUITY_TYPE + ""), merged); } + /** + * Returning the fragment unchanged is still the answer when nothing depends on the array. + * + *

This test used to pass a continuity type and assert the same thing, which is the + * behaviour that shipped the feature inert: the caller sees the key and writes no array, this + * writes nothing into the one that is there, and the type ends up in no array at all. Its + * real subject -- that a value which is not an array is never edited -- is unchanged and now + * asked without a continuity type; the refusal has tests of its own above.

+ */ @Test - void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() { + void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() throws BuildException { String inject = "NSUserActivityTypesnot an array"; - assertEquals(inject, - IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE)); + assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null)); } /** * The parser has to accept the shapes a hand-written fragment really carries. */ @Test - void aSpacedClosingTagIsStillMergedInto() { + void aSpacedClosingTagIsStillMergedInto() throws BuildException { String inject = "NSUserActivityTypes" + "com.example.app.legacyHandoff"; @@ -436,7 +500,7 @@ void aSpacedClosingTagIsStillMergedInto() { * than a duplicate key, because nothing says so until Handoff does not work on a device. */ @Test - void aSelfClosingArrayIsExpandedSoTheMergeCanSeeIt() { + void aSelfClosingArrayIsExpandedSoTheMergeCanSeeIt() throws BuildException { String inject = "NSUserActivityTypes"; String expanded = IPhoneBuilder.expandEmptyUserActivityArray(inject); @@ -447,7 +511,7 @@ void aSelfClosingArrayIsExpandedSoTheMergeCanSeeIt() { } @Test - void aSelfClosingArrayWithWhitespaceAndASpacedTagIsStillExpanded() { + void aSelfClosingArrayWithWhitespaceAndASpacedTagIsStillExpanded() throws BuildException { String inject = "NSUserActivityTypes\n "; String merged = IPhoneBuilder.mergeUserActivityTypes( @@ -517,7 +581,7 @@ void aLiveDeclarationBesideACommentedOneCountsAsSupplied() { * without the continuity type and Handoff was never advertised. */ @Test - void aCommentedDeclarationAboveALiveOneIsNotTheOneMergedInto() { + void aCommentedDeclarationAboveALiveOneIsNotTheOneMergedInto() throws BuildException { String inject = "" + "NSUserActivityTypes" @@ -566,7 +630,7 @@ void anUnterminatedCommentSwallowsWhatFollows() { * self-closing, and the merge then found no closing tag and added nothing. */ @Test - void aCommentBetweenTheKeyAndItsArrayIsSteppedOver() { + void aCommentBetweenTheKeyAndItsArrayIsSteppedOver() throws BuildException { String inject = "NSUserActivityTypes"; String merged = IPhoneBuilder.mergeUserActivityTypes( @@ -577,19 +641,31 @@ void aCommentBetweenTheKeyAndItsArrayIsSteppedOver() { } /** - * 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. + * When this key's value is not an array, the ids never reach a LATER key's array. An unbounded + * search reached past it and inserted them into the next array it found, corrupting a property + * this code was never asked about. + * + *

Asked both ways, because the refusal must not be mistaken for the guarantee. Without a + * continuity type the fragment comes back untouched, which is what proves nothing was + * borrowed; with one the build is refused, and the refusal has to happen INSTEAD of the + * corruption rather than after it -- so the unrelated array is checked in the message-free + * path where it could actually have been edited.

*/ @Test - void aNonArrayValueDoesNotBorrowALaterKeysArray() { + void aNonArrayValueDoesNotBorrowALaterKeysArray() throws BuildException { String inject = "NSUserActivityTypesnot an array" + "SomethingElsekeep"; - String merged = IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), - CONTINUITY_TYPE); + assertEquals(inject, + IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), null), + "an unrelated array was edited"); - assertEquals(inject, merged, "an unrelated array was edited"); + try { + IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), CONTINUITY_TYPE); + fail("a continuity type with nowhere to go must not be accepted"); + } catch (BuildException expected) { + assertFalse(expected.getMessage().contains("SomethingElse"), expected.getMessage()); + } } @Test @@ -621,7 +697,7 @@ void anUnterminatedCommentYieldsNoImmediateValue() { * activity type -- while the branch that decided to merge had resolved the key correctly. */ @Test - void aCommentInsideTheKeyDoesNotEndItEarly() { + void aCommentInsideTheKeyDoesNotEndItEarly() throws BuildException { String inject = "NSUserActivityTypes"; String merged = IPhoneBuilder.mergeUserActivityTypes( @@ -631,7 +707,7 @@ void aCommentInsideTheKeyDoesNotEndItEarly() { } @Test - void nothingToAddLeavesTheFragmentAlone() { + void nothingToAddLeavesTheFragmentAlone() throws BuildException { String inject = "NSUserActivityTypes" + "com.example.app.legacyHandoff"; 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 index b42d2a7287b..66e7a00c6de 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -1574,6 +1574,55 @@ public void theLiveHighWaterMapIsBounded() { "the live map grew past its cap: " + Continuity.seenSizeForTest()); } + /** + * The marks stay writable when the device ids are long. + * + *

MAX_SEEN bounds the COUNT, and that is not the bound storage imposes. The whole map goes + * out as ONE string, written as modified UTF-8 with a length that stops at 65535 bytes, while + * a device id is only checked against that limit one at a time on its way into an AppState -- + * and ids arrive from other devices. A handful of large ones therefore make a combined string + * no write can hold.

+ * + *

The failure is the quiet kind, which is why it is asserted on the PERSISTED map rather + * than the live one: this run keeps acknowledging correctly from memory, and nothing reaches + * the disk, so after every restart the relay offers an already-applied state again and its + * side effects run a second time.

+ */ + @EdtTest + public void longDeviceIdsDoNotStopTheMarksFromBeingStored() { + // A provider and a payload, because a mark only becomes durable once the state has been + // APPLIED -- commit() writes nothing for a state that restored nothing. A first version + // of this test delivered bare states and asserted on a file nothing had written yet, and + // it failed identically with SHORT ids, which is what showed the fixture was wrong rather + // than the code under test. + Continuity.setStateProvider(new RecordingProvider()); + + StringBuilder pad = new StringBuilder(); + for (int i = 0; i < 4000; i++) { + pad.append('d'); + } + // Forty of these is roughly 160KB of ids, so the combined string is far past what one + // stored string can hold while every id on its own is comfortably legal. + String newest = null; + for (int i = 0; i < 40; i++) { + newest = "device-" + i + "-" + pad; + Map payload = new HashMap(); + payload.put("note", "from " + i); + Continuity.deliver(new AppState() + .setPayload(payload) + .setDeviceId(newest) + .setSequence(i + 1) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + } + + Map persisted = Continuity.readSeenForTest(); + assertTrue(persisted.containsKey(newest), + "the marks could not be written once the ids were long, so the most recent " + + "acknowledgement is not durable and that state will be offered again " + + "after a restart; persisted=" + persisted.size()); + } + /** * A provider that throws is an attempt that FAILED, not an absence of work. It happens * transiently -- a dependency that is not up yet on a cold launch is the ordinary cause -- From a81c1f09d17d9c1b328bf4d2299022a4cfe57b7c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:43:35 +0300 Subject: [PATCH 054/140] iOS: a failed synchronize no longer makes the local synced store unusable NSUbiquitousKeyValueStore is a LOCAL persistent store. Reads and writes go to disk and iCloud propagation happens asynchronously afterwards, and -synchronize pushes the in-memory copy to that disk rather than to the network. The store was handed out only if a synchronize had just answered YES, so for as long as a NO lasted the feature was off: get() ignored values already cached locally, and put() and remove() did nothing at all -- discarding work that would have persisted, and propagated, perfectly well. Whether the store is USABLE and whether the build is ENTITLED are separate questions, and the probe only ever answered the second: Apple gives a missing entitlement as the example of what makes synchronize return NO. So the store is now retained on the strength of existing, and the probe sets a flag of its own that isSupported() reports. An unentitled build is no worse off than the nil it used to get, and an entitled one recovers everything written meanwhile. Reporting "there is a store object" as support would have been the easy version and the wrong one -- it tells an unentitled app the feature works and its values sit on that device for ever. The flag latches SUCCESS only, like the store beside it: an app cannot lose an entitlement while it runs, so once it answers YES every later call skips the probe and this is not a synchronize per store access. put()'s documented contract is "true when the store holds the value afterwards", which this makes true rather than breaks: the local store does hold it. The comment claiming an offline launch is the obvious cause of a NO is gone with it. synchronize is a disk operation and I could not have observed that. Syntax-checked against the real iOS SDK for arm64 with CN1_USE_CONTINUITY on: six diagnostics, all pre-existing generated-symbol warnings that master produces identically, none in this region. Proven non-vacuous by injecting a syntax error here, which takes it to seven. check-native-signatures.sh passes. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 57 ++++++++++++++++++------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 9661b9f16d0..d4ca5514eae 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20632,17 +20632,25 @@ static id cn1ContinuitySanitize(id value) { return nil; } -/// The synced store, or nil when this build did not earn one. +/// The synced store, or nil when this process has no store object at all. /// -/// Resolved once and cached, because the answer cannot change while the process runs: it is a -/// property of how the app was signed. +/// NOT gated on the synchronize probe, which answers a different question. A +/// NSUbiquitousKeyValueStore is a LOCAL persistent store: reads and writes go to disk and iCloud +/// propagation happens asynchronously afterwards, and -synchronize pushes the in-memory copy to +/// that disk rather than to the network. Refusing to hand the store out because a synchronize +/// answered NO therefore threw away work that would have persisted and propagated perfectly well +/// -- get() ignored values already cached locally, and put() and remove() did nothing at all -- +/// for as long as the NO lasted. /// -/// 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. +/// Whether the store is USABLE and whether this build is ENTITLED are separate questions, and the +/// probe belongs to the second one. See cn1ContinuitySyncEntitled. +/// Whether a synchronize has ever succeeded, which is how a missing entitlement shows itself. +/// +/// File scope rather than a function static so isSupported() can read it: what the application is +/// told about support has to be this, and not "is there a store object", which is now yes for an +/// unentitled build too. +static BOOL cn1ContinuitySyncEntitled = NO; + static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) { static NSUbiquitousKeyValueStore *store = nil; static pthread_mutex_t cn1ContinuityStoreLock = PTHREAD_MUTEX_INITIALIZER; @@ -20654,12 +20662,15 @@ static id cn1ContinuitySanitize(id value) { // 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 + // But it must NOT latch failure. [s synchronize] is the probe for whether this build is + // ENTITLED -- Apple gives a missing entitlement as the example of what makes it answer NO -- + // and a one-time initializer cached that NO for the life of the process. An app whose first + // probe failed for any other reason then reported the synced store unsupported forever, with + // no observer, however long the process ran. Probing again on a later call costs one // synchronize; getting it permanently wrong costs the feature. + // + // The probe is deliberately not asked to decide more than that. It is a DISK sync, so its + // answer says nothing about whether the local store can hold a value. pthread_mutex_lock(&cn1ContinuityStoreLock); @try { NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore]; @@ -20683,9 +20694,19 @@ static id cn1ContinuitySanitize(id value) { CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); }] retain]; } - if (store == nil && s != nil && [s synchronize]) { + if (store == nil && s != nil) { + // Retained on the strength of the store EXISTING. A store that is never entitled + // still holds values locally, and holding them beats discarding them: the entitled + // case recovers everything written meanwhile, and the unentitled case is no worse off + // than the nil this used to return. store = [s retain]; } + if (!cn1ContinuitySyncEntitled && s != nil && [s synchronize]) { + // Latches SUCCESS only, like the store beside it. Once it has answered YES the + // question is settled -- an app cannot lose an entitlement while it runs -- and + // every later call skips the probe, so this is not a synchronize per store access. + cn1ContinuitySyncEntitled = YES; + } } @catch (NSException *e) { // Deliberately NOT "store = nil". The handler now covers calls that already resolved -- // it moved out of the store == nil guard when the observer stopped depending on the probe @@ -20756,7 +20777,11 @@ void com_codename1_impl_ios_IOSNative_continuityClear__(CN1_THREAD_STATE_MULTI_A } JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { - return cn1ContinuityStore() != nil ? JAVA_TRUE : JAVA_FALSE; + // Called for the side effect as much as the value: resolving is what runs the probe, and the + // flag it sets is the answer. Reporting "there is a store object" instead would tell an + // unentitled app the feature works, and its values would sit on that device for ever. + NSUbiquitousKeyValueStore *resolved = cn1ContinuityStore(); + return (resolved != nil && cn1ContinuitySyncEntitled) ? JAVA_TRUE : JAVA_FALSE; } 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) { From 190a2234a06fd7411082b8b5dd8972768b4da150 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:22:00 +0300 Subject: [PATCH 055/140] Continuity: three silent losses at the relay and logout boundaries A poll no longer releases a queued publish before the state it fetched has been dispatched. admit() deliberately queues that dispatch for a LATER turn -- the second turn is what lets an older state notice it was superseded -- so `parked` was still null when the publisher started in the same turn, and the hold that stops a checkpoint overwriting the relay's only copy of an unhandled arrival had nothing to see. The worker never looks at `parked` again. The publish is now queued behind the dispatch, where it reads the answer instead of racing it. Logout no longer trusts a delete it cannot observe. deleteStorageFile() returns void and the ports discard the answer they do get -- JavaSE ignores File.delete()'s boolean, Android ignores Context.deleteFile()'s -- so a refused deletion left the signed-out account's routes and payload on disk, ready to be restored into the next login. The entry is overwritten first, and THAT is checked. Deliberately not with an empty AppState: readStored() answers null for anything that is not one, so a plain empty string leaves getRestorableState() null exactly as a successful delete would, where a blank state would have been offered for restoration instead. And a relay that was replaced now refuses on the credential path. The publish worker confirms its session on the event thread first, but it is a different thread, so between that confirmation and the relay reading its token a logout and a login can both have happened -- and getToken() is read at each request by design, so an object kept across both answers with whoever is signed in NOW. The previous account's state would go out under the next account's credentials. The guard is in auth(), covering publish and fetch, immediately before the token is read. It checks the relay is still installed, which is what closing this actually requires of an application: install the new account's relay rather than swapping a token inside the old object. clear() deliberately keeps the relay -- the same endpoint usually serves the next account -- so the identity is the only thing the framework can recognise, and getToken() now documents it. An application that mutates one relay across accounts is beyond any framework check and is told so, rather than being left to assume the hole is closed. Each proven by reverting it alone. The poll-ordering test took three attempts: it first passed with the fix disabled, because nothing was ever owed to the relay, so it now holds the fetch open, queues a checkpoint while the poll is in flight, and requires the publish to arrive once the arrival is acknowledged -- without that last step the empty check would pass against a relay nothing wanted to write to. isInstalledRelay takes the tree's //NOPMD marker: identity is the point, and CompareObjectsWithEquals fails the build for any finding. Full module 6243/6243, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 65 +++++- .../codename1/continuity/RestStateRelay.java | 29 ++- .../continuity/LocalContinuityTest.java | 211 ++++++++++++++++++ 3 files changed, 303 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 755122a39fe..a7983aaa617 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -986,8 +986,10 @@ private static void pollFinished(AppState fetched, boolean fetchFailed, int sess return; } polling = false; + boolean admitted = false; if (fetched != null) { admit(fetched); + admitted = true; } if (pollAgain) { pollAgain = false; @@ -1001,7 +1003,27 @@ private static void pollFinished(AppState fetched, boolean fetchFailed, int sess // establishes nothing, so anything owed waits for a read that succeeds. return; } - // Owed work goes out AFTER the fetch, never before it. + // Owed work goes out AFTER the fetch, never before it -- and after the fetched state has + // been DISPATCHED, not merely admitted. + // + // admit() only queues the dispatch for a later turn, on purpose: a second turn is what + // lets an older state notice it was superseded. So `parked` is still null here, and + // startPublisher()'s hold on a parked arrival -- the guard that stops a publish from + // overwriting the relay's only copy of a state nobody has dealt with yet -- had nothing + // to see. The worker it started never looks at `parked` again. + // + // Queued behind the dispatch rather than run beside it. Serial calls keep their order, so + // this runs after the turn that decides whether the arrival parks, and it then reads the + // answer instead of racing it. + if (admitted) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + startPublisher(); + } + }); + return; + } startPublisher(); } @@ -1046,7 +1068,26 @@ public static void clear() { clearContinuation(); try { if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) { + // Overwritten BEFORE it is deleted, and the overwrite is the part that is + // checked. deleteStorageFile() returns void, and the ports behind it discard the + // answer they do get -- JavaSE ignores File.delete()'s boolean, Android ignores + // Context.deleteFile()'s -- so a refused deletion is invisible here and leaves + // the signed-out account's routes and payload on disk, ready to be restored into + // the next login. That is the one thing logout must not do. + // + // The replacement is deliberately NOT an empty AppState: readStored() answers + // null for anything that is not an AppState, so a plain empty string leaves + // getRestorableState() null exactly as a successful delete would, while a blank + // state would have been offered for restoration instead. + boolean blanked = Storage.getInstance().writeObject(STORAGE_KEY, ""); Storage.getInstance().deleteStorageFile(STORAGE_KEY); + if (!blanked && Storage.getInstance().exists(STORAGE_KEY)) { + // Both attempts refused, which is the only case the user can still be hurt + // by. Said plainly, because the alternative is a silent logout that did not + // take -- and there is nothing further this can do about it. + Log.p("Continuity: the stored checkpoint could not be removed on logout, so " + + "the previous account's state may be restored after a restart."); + } } } catch (Throwable t) { Log.e(t); @@ -1732,6 +1773,28 @@ private static void noteActedOn(AppState state) { rememberSeen(); } + /// Whether `r` is still the relay this application has installed. + /// + /// Package visible for RestStateRelay, which asks it on the CREDENTIAL path -- the last place + /// a check can be put before a token is read. The publish worker already confirms its session + /// on the event thread before calling the relay, and that leaves one gap it cannot close: the + /// worker is a different thread, so between the confirmation returning and the relay reading + /// its token, a logout and a login can both have happened. getToken() is documented to be + /// read at each request precisely so a refreshed session is followed, which means the relay + /// would then authenticate the PREVIOUS account's state with the NEXT account's credentials. + /// + /// Not the same as the session check and not a replacement for it: that one stops the work, + /// this one stops the credentials. A check cannot be atomic with the read that follows it, so + /// what this buys is the distance between them -- instructions on one thread instead of an + /// unbounded wait on a queue. Closing it completely would mean binding the token inside the + /// confirmation, which no framework code can do: resolving credentials is the relay's own + /// business and the interface deliberately does not reach into it. + static boolean isInstalledRelay(StateRelay r) { + // IDENTITY, and the marker says so: two relays that considered themselves equal would + // still be two objects, and the one that was replaced is the one that must be refused. + return r != null && r == relay; //NOPMD CompareObjectsWithEquals + } + /// Test seam: parks a state, as a cold-launch arrival with no form yet does. static void parkForTest(AppState state) { parked = state; diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index 3dfa8df882d..67c4826894d 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -105,6 +105,16 @@ public String getUrl() { /// #### Returns /// /// the token, or null for none + /// + /// #### Changing accounts + /// + /// Install a NEW relay for the new account -- `Continuity.setRelay(StateRelay)` -- rather than + /// returning a different account's token from the same object. A publish that was authorised + /// for the previous account can still be between the framework's last check and this read + /// when the switch happens, and the framework cannot bind a token it is not allowed to read. + /// What it can recognise is an object that is no longer installed, which it then refuses; an + /// object that quietly starts answering for someone else looks identical to one that + /// refreshed its own session. protected String getToken() { return null; } @@ -139,7 +149,24 @@ public AppState fetch() throws IOException { return StateCodec.fromJson(response.getResponseData()); } - private RequestBuilder auth(RequestBuilder b) { + /// Adds the bearer token, refusing outright if this relay is no longer the installed one. + /// + /// The refusal is HERE, immediately before the token is read, because that is what makes it + /// worth anything. A worker that was started for one account and reaches the network after + /// the user has signed out and back in would otherwise send the first account's state + /// authenticated as the second: getToken() is read at each request, by design, so the same + /// relay object answers with whoever is signed in NOW. + /// + /// Continuity stops such a worker before it calls a relay at all. This is the second line for + /// the gap that check cannot cover -- it runs on the event thread, and the worker is not it. + /// Throwing rather than skipping quietly, so the framework records the publish as failed and + /// keeps owing it, and the state is republished once a relay is installed again. + private RequestBuilder auth(RequestBuilder b) throws IOException { + if (!Continuity.isInstalledRelay(this)) { + throw new IOException("This relay is no longer installed -- Continuity.clear() or " + + "setRelay() replaced it. Refusing the request rather than sending one " + + "account's state under another account's credentials."); + } String token = getToken(); return token == null || token.length() == 0 ? b : b.bearer(token); } 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 index 66e7a00c6de..63ccf93c721 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -44,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * The framework against the simulated platform every non-Apple port and the simulator use. @@ -2194,6 +2195,216 @@ public void aCaptureWhoseSequenceCannotBeStoredIsRefused() { + "counter that produced it and later states are silently ignored"); } + /** + * A poll that brings back a state nobody has dealt with yet must not release a queued publish. + * + *

The publisher's hold on a parked arrival exists because a parked state's only copy is on + * the relay, and publishing replaces that single document. admit() deliberately queues the + * dispatch for a LATER turn -- that second turn is what lets an older state notice it was + * superseded -- so when the poll finished in the same turn, {@code parked} was still null and + * the hold had nothing to see. The worker it started never looks again.

+ */ + @EdtTest + public void aFetchedStateNobodyHasHandledYetHoldsTheQueuedPublish() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + // Nothing applies the arrival, so it parks -- the state the hold is for. + Continuity.setAutoRestore(false); + + final AppState waiting = fromElsewhere("fetched, unhandled", 91L); + final java.util.concurrent.CountDownLatch inFetch = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() { + if (!served.getAndSet(false)) { + return null; + } + // Held open so the checkpoint below is queued WHILE the poll is running, which is + // the situation the finding is about: work owed to the relay, and a state coming + // back that nobody has looked at yet. + inFetch.countDown(); + try { + release.await(2L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return waiting; + } + }); + + awaitOffEdt(new Runnable() { + public void run() { + try { + inFetch.await(2L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + // Owed while the poll is in flight, so pollFinished() is what releases it. + Continuity.checkpoint(); + release.countDown(); + + // Two turns: the one pollFinished() runs in, and the one it queues the dispatch into. + pause(400L); + flushSerialCalls(); + pause(200L); + flushSerialCalls(); + + assertNotNull(Continuity.getRestorableState(), + "the fetched state never parked, so this test is not about the hold at all"); + assertTrue(published.isEmpty(), + "a checkpoint was published over the relay's only copy of a state the " + + "application has not dealt with yet: published=" + published.size()); + + // And it was genuinely HELD, not simply never owed. Acknowledging the arrival is what + // releases the hold, so a publish arriving now is the proof that one was waiting -- and + // without it the assertion above would pass just as well on a relay nothing ever wanted + // to write to, which is how the first version of this test proved nothing. + Continuity.acknowledge(waiting); + pause(300L); + flushSerialCalls(); + assertFalse(published.isEmpty(), + "no publish followed the acknowledgement, so nothing was ever owed to the relay " + + "and the empty check above was vacuous"); + } + + /** What the fetch above hands over, once. */ + private final java.util.concurrent.atomic.AtomicBoolean served = + new java.util.concurrent.atomic.AtomicBoolean(true); + + /** What that relay was asked to publish. */ + private final List published = + java.util.Collections.synchronizedList(new ArrayList()); + + /** + * Logout has to leave nothing restorable even when the port refuses to delete the file. + * + *

{@code deleteStorageFile} returns void and the ports behind it discard the answer they do + * get -- JavaSE ignores {@code File.delete()}'s boolean, Android ignores + * {@code Context.deleteFile()}'s -- so a refused deletion is invisible and leaves the + * signed-out account's routes and payload on disk, ready to be restored into the next + * login.

+ */ + @EdtTest + public void aLogoutLeavesNothingRestorableEvenWhenTheDeleteIsRefused() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("secret", "the previous account's work"); + Continuity.setStateProvider(provider); + Continuity.checkpoint(); + assertNotNull(Continuity.getRestorableState(), + "there is no checkpoint to lose, so the assertion below would pass on nothing"); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new UndeletableStorage(original)); + try { + Continuity.clear(); + } finally { + Storage.setStorageInstance(original); + } + + assertNull(Continuity.getRestorableState(), + "logout left the previous account's checkpoint on disk, so a restart restores " + + "one account's routes and payload into the next account's session"); + } + + /** Storage whose deleteStorageFile does nothing at all, silently, as a refusing port does. */ + static class UndeletableStorage extends Storage { + private final Storage delegate; + + UndeletableStorage(Storage delegate) { + this.delegate = delegate; + } + + @Override + public void deleteStorageFile(String name) { + // Deliberately nothing. This is the port behaviour under test: the entry survives and + // the caller is told nothing, because the method cannot tell it anything. + } + + @Override + public boolean writeObject(String name, Object o) { + return delegate.writeObject(name, o); + } + + @Override + public Object readObject(String name) { + return delegate.readObject(name); + } + + @Override + public boolean exists(String name) { + return delegate.exists(name); + } + } + + /** + * A relay that is no longer installed refuses on the credential path, before it reads a token. + * + *

The publish worker confirms its session on the event thread before calling a relay, and + * that leaves one gap: the worker is a different thread, so between the confirmation and the + * relay reading its token a logout and a login can both have happened. + * {@code RestStateRelay.getToken()} is read at each request by design, so an object kept + * across both would answer with whoever is signed in NOW -- and the previous account's state + * would go out under the next account's credentials.

+ * + *

What closes it is installing the new account's relay, which is the documented way to + * change accounts: the replaced object is refused for good. An application that instead keeps + * one relay and swaps the token inside it is beyond what any framework check can see, and + * getToken() says so.

+ * + *

No server is involved: the refusal is the first thing {@code auth} does, so reaching the + * network at all would be the failure.

+ */ + @EdtTest + public void aRelayThatIsNoLongerInstalledRefusesBeforeReadingItsToken() { + final boolean[] tokenRead = new boolean[1]; + RestStateRelay relay = new RestStateRelay("https://example.invalid/continuity") { + @Override + protected String getToken() { + tokenRead[0] = true; + return "the-next-account-token"; + } + }; + + Continuity.setRelay(relay); + // The control: while it IS installed the guard must not fire, or the assertion below + // would pass against a relay that simply never works. + assertTrue(Continuity.isInstalledRelay(relay), + "the relay was not installed, so the refusal below proves nothing"); + + // REPLACED, not cleared. clear() is a logout and deliberately keeps the relay installed: + // the same endpoint usually serves the next account. What the framework can recognise is + // a relay that is no longer the installed one, which is why switching accounts means + // installing the new account's relay rather than swapping a token inside the old object. + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + } + + public AppState fetch() { + return null; + } + }); + + try { + relay.publish(new AppState().setDeviceId("previous-account").setSequence(1L)); + fail("a relay that setRelay() replaced must not send anything"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().contains("no longer installed"), + expected.getMessage()); + } + assertFalse(tokenRead[0], + "the token was read before the relay noticed it had been removed, which is the " + + "credential the previous account's state would have gone out under"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 243014e7c58512879cd707a9f653d2017c2e997d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:46:12 +0300 Subject: [PATCH 056/140] Continuity: three arrivals that ended without their bookkeeping All three are the same shape -- a state transition that was not paired with the slot or the mark that goes with it -- and two of them lose the user's work. A restore that FAILED no longer releases the parked slot. restore(AppState) deliberately does not acknowledge a failed attempt, because a provider that throws is usually transient and the state should stay on the relay for a launch that can use it. The no-argument restore() cleared the slot anyway, and both halves of that hurt: admit() has already put the sequence in the live map, so nothing offers the state again this run, and releasing the hold lets a checkpoint overwrite the relay's only copy. The retry it was being kept for then had nothing to retry. `shown` cannot answer this -- a payload-only state applies everything it has and still returns false -- so the private form reports `failed` alongside it, the same split capture() and checkpoint() already needed. A listener that DEFERS an arrival now keeps it parked. False has two documented meanings, "I did the work myself" and "keep it, I will prompt and call restore() when the user accepts", and the second is a state waiting on a human whose only other copy is the relay's. Returning without the slot left no hold at all, so a queued checkpoint could replace that copy while the prompt was still on screen. Parking is right for the first meaning too: acknowledge() releases the slot, and that is the call that meaning is documented to make. A consumed TOMBSTONE is now marked durably. It is the one arrival that cannot fail -- no payload to hand over, no route to rebuild -- so there is nothing to gate the mark on, and recording it in memory only meant the next launch had never heard of it: an older state from the same origin, already in flight, passed admission and offered work the tombstone exists to say no longer exists. That last one broke anAcknowledgedOriginIsRefusedAfterItsDedupEntryIsEvicted, and the TEST was what was wrong. It crowded the map with empty states -- which are tombstones -- so once tombstones became durable completions the crowd competed for the durable map and evicted the acknowledgement the test protects. Its own premise is "every arrival versus only the completed ones", and an empty state was quietly both. The crowd is now payload-carrying arrivals under a throwing provider: admitted, never completed, which is what it always meant. It still fails against the bug it guards. Every parked transition was audited rather than these three patched: thirteen sites, of which the two above were wrong and the other two clears -- expiry in getRestorableState() and disable() -- are deliberate ends of an arrival. The success control needed correcting too. A successful restore persists the state, so getRestorableState() legitimately keeps answering afterwards with the stored checkpoint, and reading that as "the slot was never released" made the first version fail. It now asserts the hold itself: a checkpoint stays off the relay while the arrival is parked and goes out once it is restored. Full module 6247/6247, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 48 ++++- .../continuity/LocalContinuityTest.java | 175 +++++++++++++++++- 2 files changed, 218 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index a7983aaa617..0bd1589f924 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -771,10 +771,24 @@ public static boolean restore() { // only copy, and because dispatch had already written the sender's durable mark, a relay // retry was rejected after the next launch too -- a state that was never restored then // could not be restored at all, which is the one outcome this feature exists to prevent. - boolean shown = restore(state); + boolean[] failed = new boolean[1]; + boolean shown = restore(state, failed); + if (failed[0]) { + // An attempt that FAILED keeps the slot. restore(state) deliberately does not + // acknowledge one -- a provider that threw is usually transient, so the state stays + // on the relay for a launch that can use it -- and clearing here undid exactly that: + // admit() has already put the sequence in the live map, so nothing offers the state + // again this run, and releasing the publication lets a checkpoint overwrite the + // relay's only copy. The retry it was being kept for then has nothing to retry. + return shown; + } // Released because the state was APPLIED, not because a form appeared. Gating this on // `shown` kept a payload-only arrival parked for ever: restore(state) hands the payload // to the provider and returns false, so every later call re-applied the same state. + // + // `failed` is the distinction `shown` cannot make. False means both "there was no form to + // show, and that is success" and "this did not work", which need opposite handling here -- + // the same conflation that put two flags in capture() and in checkpoint(). if (isSameState(parked, state)) { parked = null; // The slot is what holds a publication back; the decision has been made, so anything @@ -820,6 +834,16 @@ public static void acknowledge(AppState state) { /// /// true when a form was shown public static boolean restore(final AppState state) { + return restore(state, new boolean[1]); + } + + /// As above, also reporting whether the attempt FAILED as opposed to having nothing to do. + /// + /// The public boolean answers "is a form showing", which is what a caller needs to decide + /// whether to start its own screen. It cannot also say whether the restore worked: a + /// payload-only state applies everything it has and still returns false. The parked slot has + /// to tell those apart, because releasing it is what allows the relay's copy to be replaced. + private static boolean restore(final AppState state, boolean[] outFailed) { if (state == null) { return false; } @@ -862,6 +886,7 @@ public static boolean restore(final AppState state) { // the worse failure of the two: a provider that only populates fields -- the // documented shape -- would leave the application on no screen at all. commit(state, applied, failed); + outFailed[0] = failed; return false; } // Applying a state is not the user navigating, and the difference is not cosmetic. The @@ -895,6 +920,7 @@ public static boolean restore(final AppState state) { failed = true; } commit(state, applied || shown, failed); + outFailed[0] = failed; return shown; } @@ -1554,6 +1580,14 @@ private static void admit(final AppState state) { parked = null; startPublisher(); } + // Durably, and here rather than through commit(). Consuming a tombstone is the one + // arrival that CANNOT fail -- there is no payload to hand over and no route to + // rebuild -- so there is nothing to gate the mark on, and leaving it in memory only + // meant the next launch had never heard of it. An older state from the same origin + // that was already in flight then passed admission and offered work this tombstone + // exists to say no longer exists. + recordDurable(state.getDeviceId(), state.getSequence()); + rememberSeen(); return; } Display.getInstance().callSerially(new Runnable() { @@ -1612,6 +1646,18 @@ private static void dispatch(AppState state) { if (!accepted) { // Consumed by the listener: it either handled the state itself or decided the user // must not be moved. Asking the next listener would undo that decision. + // + // PARKED, not simply dropped. False has two documented meanings -- "I did the work + // myself" and "keep it, I will prompt and call restore() when the user accepts" -- + // and the second one is a state waiting on a human, whose only other copy is the + // relay's. Returning without the slot left no hold, so a queued checkpoint could + // replace that copy while the prompt was still up, and a process death before the + // answer lost the work for good. + // + // Safe for the first meaning too: acknowledge() releases the slot, which is the + // call that meaning is documented to make. Whichever the application meant, the + // hold ends when it says so rather than being guessed at here. + parked = state; return; } } 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 index 63ccf93c721..325952e7a5a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2405,6 +2405,155 @@ public AppState fetch() { + "credential the previous account's state would have gone out under"); } + /** + * A restore that FAILED keeps the parked state, and keeps the publication held with it. + * + *

restore(AppState) deliberately does not acknowledge a failed attempt -- a provider that + * throws is usually transient, a dependency not up yet on a cold launch -- so the state stays + * on the relay for a launch that can use it. The no-argument restore() cleared the slot + * anyway, and both halves of that hurt: admit() has already put the sequence in the live map, + * so nothing offers the state again this run, and releasing the hold lets a checkpoint + * overwrite the relay's only copy. The retry it was being kept for has nothing left to + * retry.

+ */ + @EdtTest + public void aFailedRestoreKeepsTheParkedState() { + Continuity.setAutoRestore(false); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + throw new IllegalStateException("the dependency this needs is not up yet"); + } + }); + + AppState arrival = fromElsewhere("work worth keeping", 41L); + arrival.setRoutes(new ArrayList()); + Continuity.deliver(arrival); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), + "the arrival never parked, so there is no slot for the restore to lose"); + + assertFalse(Continuity.restore(), "a provider that threw cannot have shown anything"); + + assertNotNull(Continuity.getRestorableState(), + "a restore that failed threw away the only copy it was keeping: nothing offers " + + "the state again this run and the relay's copy is now replaceable"); + } + + /** + * And the control: a restore that WORKED still releases the slot. + * + *

Without this the fix above is satisfied by never clearing at all, which would keep a + * handled arrival on offer for ever and hold every later checkpoint behind it.

+ */ + @EdtTest + public void aSuccessfulRestoreStillReleasesTheParkedState() { + Continuity.setAutoRestore(false); + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + final List out = java.util.Collections.synchronizedList(new ArrayList()); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + out.add(state); + } + + public AppState fetch() { + return null; + } + }); + pause(200L); + flushSerialCalls(); + out.clear(); + + AppState arrival = fromElsewhere("work that applies", 42L); + arrival.setRoutes(new ArrayList()); + Continuity.deliver(arrival); + flushSerialCalls(); + + // Asserted on the HOLD, not on getRestorableState(). A successful restore persists the + // state, so that method legitimately keeps answering afterwards -- with the stored + // checkpoint rather than the parked arrival -- and a first version of this test read that + // as the slot never being released. + Continuity.checkpoint(); + pause(200L); + flushSerialCalls(); + assertTrue(out.isEmpty(), "the parked arrival did not hold the checkpoint back at all"); + + Continuity.restore(); + pause(300L); + flushSerialCalls(); + + assertFalse(out.isEmpty(), + "a restore that applied the payload never released the hold, so this arrival " + + "would keep every later checkpoint off the relay for good"); + } + + /** + * A listener that defers an arrival keeps it parked. + * + *

False has two documented meanings: "I did the work myself" and "keep it, I will prompt + * and call restore() when the user accepts". The second is a state waiting on a human whose + * only other copy is the relay's, and returning without the slot left no hold at all -- so a + * queued checkpoint could replace that copy while the prompt was still on screen, and a + * process death before the answer lost the work.

+ */ + @EdtTest + public void aListenerThatDefersAnArrivalKeepsItParked() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + // "Keep it, I am prompting" -- the documented deferral. + return false; + } + }); + + Continuity.deliver(fromElsewhere("waiting on the user", 43L)); + flushSerialCalls(); + + AppState offered = Continuity.getRestorableState(); + assertNotNull(offered, + "a deferred arrival was dropped, so nothing holds a checkpoint off the relay's " + + "only copy while the user is being asked about it"); + assertEquals(43L, offered.getSequence(), "a different state was left on offer"); + + // And the hold really ends when the application says so, rather than never. + Continuity.acknowledge(offered); + assertNull(Continuity.getRestorableState(), + "acknowledge() did not release the deferred state, which would hold every later " + + "checkpoint behind an arrival the application has finished with"); + } + + /** + * A consumed tombstone is marked durably. + * + *

It is the one arrival that cannot fail -- no payload to hand over, no route to rebuild -- + * so there is nothing to gate the mark on. Recording it in memory only meant the next launch + * had never heard of it, and an older state from the same origin that was already in flight + * passed admission and offered work the tombstone exists to say no longer exists.

+ */ + @EdtTest + public void aConsumedTombstoneIsMarkedDurably() { + Continuity.setStateProvider(new RecordingProvider()); + + AppState tombstone = new AppState() + .setDeviceId("some-other-device") + .setSequence(77L) + .setTimestamp(System.currentTimeMillis()); + assertTrue(tombstone.isEmpty(), "this is not a tombstone, so the test is about nothing"); + Continuity.deliver(tombstone); + flushSerialCalls(); + + Map persisted = Continuity.readSeenForTest(); + Long mark = persisted.get("some-other-device"); + assertNotNull(mark, + "the tombstone was consumed without a durable mark, so after a restart an older " + + "state still in flight from that origin resurrects the work it cleared"); + assertEquals(77L, mark.longValue(), "the durable mark is not the tombstone's sequence"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; @@ -2538,15 +2687,33 @@ public void run() { */ @EdtTest public void anAcknowledgedOriginIsRefusedAfterItsDedupEntryIsEvicted() { - RecordingProvider provider = new RecordingProvider(); - Continuity.setStateProvider(provider); - AppState handled = fromElsewhere("dealt with", 5L); Continuity.acknowledge(handled); - // Enough other origins to push it out of the in-memory map, which is capped at 64. + // The crowd has to be arrivals that were ADMITTED and never COMPLETED, which is the + // difference between the two maps this test is about. A provider that throws is the + // cheapest way to say that: every one of these enters lastSeen on admission and none of + // them earns a durable mark. + // + // They used to be empty states, which is a tombstone -- and a consumed tombstone is a + // completed arrival, so once tombstones started being marked durably the crowd competed + // for the durable map too and evicted the acknowledgement this test exists to protect. + // The failure was real and the test was the thing that was wrong: its own premise is + // "every arrival versus only the completed ones", and an empty state is both. + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + throw new IllegalStateException("nothing here completes"); + } + }); for (int i = 0; i < 90; i++) { + Map payload = new HashMap(); + payload.put("crowd", Integer.valueOf(i)); Continuity.deliver(new AppState() + .setPayload(payload) .setDeviceId("crowd-" + i) .setSequence(i + 1) .setTimestamp(System.currentTimeMillis())); From ab6d0eab5e71168e944fb0cb139f9a96f8591dda Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:21:22 +0300 Subject: [PATCH 057/140] Continuity: four more silent losses, one of them mine from last round The synced store fix from the previous commit was INERT. IOSNative.m was changed to retain the store whether or not the synchronize probe succeeded -- and the Java bridge still gated every put, get, remove and key enumeration on isSyncedStoreSupported(), which IS that probe. So the retained store was unreachable: values already cached read as absent and writes were dropped, which is the whole of what retaining it was for. Compiling the native side and proving the check non-vacuous said nothing about whether anything could reach it. The gate on those calls is now the PORT. Whether the build is entitled to a store that follows the user is the right answer for an application deciding to offer the feature and the wrong one for the calls themselves, because the store is local: reads and writes work and propagate later. Parking a deferred arrival, added last round, broke the documented handle-it-yourself pattern -- the listener calls acknowledge(state) INSIDE stateReceived and then returns false. The acknowledgement runs first, while there is nothing parked for it to release, so the park that followed left a finished arrival on offer with every relay checkpoint held behind it for the rest of the process. It now parks only what has not already been marked handled. A checkpoint queued before a restore is no longer sent after it. A navigation while a relay GET is in flight leaves one in the slot, and it describes a screen the restore has replaced -- sending it puts superseded work over the relay's copy of the state just accepted. Dropped rather than recaptured: what the screen shows now IS the state that arrived, so a fresh capture would publish the fetch straight back under this device's id. That contradicted aCheckpointWaitsWhileAnArrivalIsParked, written earlier in this same task, which required the held checkpoint to go out once the user decided. Both are right for different decisions: acknowledging changes nothing on screen, so the held work is still true and must be sent -- that is the case the test now uses -- while restoring replaces it. The hold is still not a place things vanish into. And logout forgets the route history. A stack is the previous account's work as surely as a checkpoint is: back() reopened their forms, and the next navigation checkpointed and republished a stack that still began with their routes, sending straight back out what clear() had just deleted. Navigation gains clearStack(), which deliberately does not notify continuity -- a checkpoint there would write the emptied stack over what is being removed. Two of the new tests were wrong before they were right, both caught by running the whole class rather than the test alone. One ignored the result of a latch wait and carried on when the relay GET had never started, so the window it needed was never open; it now blocks on a publish it can prove is in flight, which needs no timing at all. The other installed a global RouteDispatcher answering every path with a form and left it there, so two unrelated tests about routes this build does not register found them all dispatchable. Full module 6250/6250, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and native-signature gates clean. The three core fixes each proven by reverting them alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 48 ++++- .../src/com/codename1/router/Navigation.java | 16 ++ .../impl/ios/IOSContinuityBridge.java | 19 +- .../continuity/LocalContinuityTest.java | 168 +++++++++++++++++- 4 files changed, 243 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 0bd1589f924..89cd6362bab 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1092,6 +1092,19 @@ public static void clear() { // arriving, which is harder to notice than one arriving twice. rememberSeen(); clearContinuation(); + // The route history is the previous account's work as surely as the stored checkpoint is. + // Leaving it kept two promises broken: back() reopened the signed-out account's forms, + // and the next navigation checkpointed and republished a stack that still began with + // their routes -- so the state this method had just deleted went straight back out. + // + // Before the deletion below, and clearStack() deliberately does not notify continuity: + // either way round, a checkpoint here would write the emptied stack over what is being + // removed. + try { + Navigation.clearStack(); + } catch (Throwable t) { + Log.e(t); + } try { if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) { // Overwritten BEFORE it is deleted, and the overwrite is the part that is @@ -1189,6 +1202,20 @@ private static void commit(AppState state, boolean applied, boolean failed) { // directions at once. return; } + if (applied) { + // A checkpoint queued before this restore describes a screen that no longer exists. + // A navigation while a relay GET is in flight leaves one in the slot, and sending it + // afterwards replaces the relay's copy of the state just accepted with the work that + // restore superseded. Likeliest at startup, where setRelay() polls while the initial + // route is still being shown. + // + // Dropped rather than recaptured. What the screen shows now IS the state that + // arrived, so a fresh capture would publish the fetch straight back under this + // device's id -- an echo, and the start of the ping-pong applyingRestore exists to + // prevent. Nothing goes out until the user does something new. + pendingPublish = null; + publishRequested = false; + } noteActedOn(state); } @@ -1657,7 +1684,9 @@ private static void dispatch(AppState state) { // Safe for the first meaning too: acknowledge() releases the slot, which is the // call that meaning is documented to make. Whichever the application meant, the // hold ends when it says so rather than being guessed at here. - parked = state; + if (!isAlreadyActedOn(state)) { + parked = state; + } return; } } @@ -1678,6 +1707,23 @@ private static void dispatch(AppState state) { } } + /// Whether this state has already been marked handled. + /// + /// Asked before a deferred arrival is parked, because the documented handle-it-yourself + /// pattern does BOTH: the listener calls acknowledge(state) and then returns false. That runs + /// noteActedOn() first, while there is nothing parked for it to release, and parking + /// afterwards left an acknowledged state on offer for the rest of the process with every + /// relay checkpoint held behind it -- the exact hold the parking was added to provide, + /// applied to work that was already finished. + /// + /// The in-memory durable map, not the stored one: recordDurable() fills it whether or not the + /// write to storage succeeded, and what is being asked here is what this process has done, not + /// what survived to disk. + private static boolean isAlreadyActedOn(AppState state) { + Long mark = durableSeen.get(state.getDeviceId()); + return mark != null && mark.longValue() >= state.getSequence(); + } + /// Holds a cold-launch arrival until the application has a form to restore into. /// /// The waiter is a thread only because there is nothing on the EDT to wait on -- no form diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 9413fc9c2ab..ff90c09e989 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -136,6 +136,22 @@ public static List getStack() { return Collections.unmodifiableList(new ArrayList(stack)); } + /// Forgets the navigation history, leaving nothing to go back to. + /// + /// For a logout, which is the case that needs it: `Continuity.clear()` calls this, because a + /// route stack is the previous account's work as surely as a stored checkpoint is. Left in + /// place it kept two promises broken -- `Navigation#back()` reopened the signed-out account's + /// forms, and the next navigation checkpointed and republished a stack that still began with + /// their routes. + /// + /// The forms themselves are not touched: whatever is on screen stays there, and the caller + /// navigates wherever it means to go next. Nor does this notify continuity. Forgetting where + /// the user has been is not the user going somewhere, and a checkpoint here would write the + /// emptied stack straight back over the one just deleted. + public static void clearStack() { + stack.clear(); + } + /// Pop entries until `entry` is on top, then show its form via /// `Form#showBack`. Returns `true` when the entry was on the stack and /// we navigated back to it, `false` when the entry is not on the stack. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java index b1577822310..fe928a1ac6c 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java @@ -115,9 +115,20 @@ public boolean isSyncedStoreSupported() { } } + /// Gated on the PORT, not on the probe. + /// + /// isSyncedStoreSupported() answers whether this build is entitled to a store that follows + /// the user, which the native side learns from a synchronize. That is the right answer for + /// an application deciding whether to offer the feature, and the wrong gate for the calls + /// themselves: the store is a LOCAL persistent one, so reads and writes work and propagate + /// later, and refusing them here made the native side's retained store unreachable -- values + /// already cached were reported absent and writes were dropped, which is the whole of what + /// retaining it was for. + /// + /// The natives answer for themselves when there is no store at all. @Override public boolean syncedStorePut(String key, String value) { - if (!isSyncedStoreSupported()) { + if (!supported) { return false; } try { @@ -130,7 +141,7 @@ public boolean syncedStorePut(String key, String value) { @Override public String syncedStoreGet(String key) { - if (!isSyncedStoreSupported()) { + if (!supported) { return null; } try { @@ -143,7 +154,7 @@ public String syncedStoreGet(String key) { @Override public void syncedStoreRemove(String key) { - if (!isSyncedStoreSupported()) { + if (!supported) { return; } try { @@ -155,7 +166,7 @@ public void syncedStoreRemove(String key) { @Override public String[] syncedStoreKeys() { - if (!isSyncedStoreSupported()) { + if (!supported) { return new String[0]; } // The native call and the parse are what can fail, so they are what the handler covers. 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 index 325952e7a5a..5630b9ba0be 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -27,6 +27,8 @@ import com.codename1.impl.continuity.LocalContinuityBridge; import com.codename1.io.Storage; import com.codename1.junit.EdtTest; +import com.codename1.router.Navigation; +import com.codename1.router.RouteDispatcher; import com.codename1.ui.Display; import com.codename1.ui.Form; import com.codename1.junit.UITestBase; @@ -2020,7 +2022,15 @@ public void run() { + "being asked about"); // Answering releases it -- held, not dropped. - Continuity.restore(); + // + // acknowledge(), not restore(). Both are decisions, and only one of them leaves the held + // checkpoint still true: restoring REPLACES the screen it describes, so sending it + // afterwards would put the superseded work over the relay's copy of the state just + // accepted, and it is dropped on purpose -- + // aCheckpointQueuedBeforeARestoreIsNotPublishedAfterIt covers that. Acknowledging changes + // nothing on screen, so the work captured while the user was being asked is still what + // this device is doing, and it has to go out or the hold is a place things vanish into. + Continuity.acknowledge(Continuity.getRestorableState()); awaitOffEdt(new Runnable() { public void run() { r.awaitAnySince(before); @@ -2486,9 +2496,20 @@ public AppState fetch() { pause(300L); flushSerialCalls(); + // NOT the checkpoint captured before the restore: that one described the screen the + // restore replaced, and sending it would overwrite the relay's copy of the state just + // accepted. It is dropped on purpose. What has to work is the NEXT one -- if the hold + // had never been released, this would be held too and the arrival would keep every + // future checkpoint off the relay for good. + assertTrue(out.isEmpty(), "the stale pre-restore checkpoint was published after all"); + provider.saved.put("after", "work done since the restore"); + Continuity.checkpoint(); + pause(300L); + flushSerialCalls(); + assertFalse(out.isEmpty(), - "a restore that applied the payload never released the hold, so this arrival " - + "would keep every later checkpoint off the relay for good"); + "a checkpoint made after the restore was still held, so the arrival keeps every " + + "later checkpoint off the relay for good"); } /** @@ -2554,6 +2575,147 @@ public void aConsumedTombstoneIsMarkedDurably() { assertEquals(77L, mark.longValue(), "the durable mark is not the tombstone's sequence"); } + /** + * A listener that acknowledges inside stateReceived and returns false leaves nothing parked. + * + *

That is the documented handle-it-yourself pattern, and it does both things: the + * acknowledgement runs first, while there is nothing parked for it to release, so parking + * afterwards left a finished arrival on offer for the rest of the process with every relay + * checkpoint held behind it -- the hold applied to work that was already done.

+ */ + @EdtTest + public void anArrivalAcknowledgedInsideTheListenerIsNotParked() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + Continuity.acknowledge(state); + return false; + } + }); + + Continuity.deliver(fromElsewhere("handled in the callback", 51L)); + flushSerialCalls(); + + assertNull(Continuity.getRestorableState(), + "an arrival the listener had already acknowledged was parked anyway, so it stays " + + "on offer and holds every later checkpoint off the relay"); + } + + /** + * A checkpoint queued before a restore is not sent afterwards. + * + *

A navigation while a relay GET is in flight leaves that checkpoint in the slot. If the + * GET brings back a state that is restored, the queued one describes a screen that no longer + * exists -- and sending it replaces the relay's copy of the state just accepted with the work + * the restore superseded.

+ */ + @EdtTest + public void aCheckpointQueuedBeforeARestoreIsNotPublishedAfterIt() { + final RecordingProvider provider = new RecordingProvider(); + provider.saved.put("screen", "one"); + Continuity.setStateProvider(provider); + + // A relay whose FIRST publish blocks. That is what leaves a second checkpoint sitting in + // the slot, which is the state this test is about -- and it is deterministic, unlike + // racing a relay GET: an earlier version timed the fetch and passed alone while failing + // in the suite, because the window it needed was never actually open. + final java.util.concurrent.CountDownLatch inPublish = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + if (inPublish.getCount() > 0) { + inPublish.countDown(); + try { + release.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + + public AppState fetch() { + return null; + } + }); + + Continuity.checkpoint(); + final boolean[] blocked = new boolean[1]; + awaitOffEdt(new Runnable() { + public void run() { + try { + blocked[0] = inPublish.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + assertTrue(blocked[0], "no publish is in flight, so nothing would queue behind it"); + + // Queued behind the worker: this is the checkpoint a restore is about to make stale. + provider.saved.put("stale", Boolean.TRUE); + Continuity.checkpoint(); + + // And the restore that supersedes it. + Continuity.deliver(fromElsewhere("what the other device was doing", 52L)); + flushSerialCalls(); + assertTrue(provider.restored != null && provider.restored.containsKey("note"), + "the arrival was not applied, so nothing superseded the queued checkpoint and " + + "this test is about nothing"); + + release.countDown(); + pause(400L); + flushSerialCalls(); + + boolean staleWentOut = false; + for (AppState sent : published) { + if (sent.getPayload().containsKey("stale")) { + staleWentOut = true; + } + } + assertFalse(staleWentOut, + "the checkpoint queued before the restore was published over the relay's copy of " + + "the state that restore had just accepted"); + } + + /** + * Logout forgets the route history, not only the stored checkpoint. + * + *

A route stack is the previous account's work as surely as a checkpoint is. Leaving it + * kept two promises broken: back() reopened the signed-out account's forms, and the next + * navigation checkpointed and republished a stack that still began with their routes.

+ */ + @EdtTest + public void logoutForgetsTheRouteHistory() { + Continuity.setStateProvider(new RecordingProvider()); + // Restored in the finally, because the dispatcher is global: a first version left one + // installed that answered EVERY path with a form, and the tests that run after it -- the + // ones about routes this build does not register -- then found every stale route + // perfectly dispatchable and failed on an assertion that had nothing to do with them. + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String path) { + return new Form(path); + } + }); + try { + Navigation.navigate("/account/statement"); + flushSerialCalls(); + assertFalse(Navigation.getStack().isEmpty(), + "nothing was navigated, so there is no history for logout to forget"); + + Continuity.clear(); + + assertTrue(Navigation.getStack().isEmpty(), + "logout left the signed-out account's route history in place, so back() " + + "reopens their forms and the next navigation republishes them"); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 0bc0e306e8e3b70c55db399d739ab039b00f665b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:44:56 +0300 Subject: [PATCH 058/140] Continuity: the synced store finally reaches the platform, and two failures reach their caller The probe gate was on THREE layers -- IOSNative.m, IOSContinuityBridge and the public SyncedStore facade -- and the last two commits removed it from the first two. Neither changed anything, because the facade an application actually calls still refused every put, get, remove and key enumeration until the entitlement probe had succeeded. Two rounds of "compiled, checked, proven non-vacuous" that established nothing about whether one call could reach the code they changed. isSupported() asks whether this build has a store that FOLLOWS THE USER, which is the right question for an application deciding to offer the feature and the wrong gate for the calls: on iOS the store is local and its cloud propagation is asynchronous, so reads and writes work and reach other devices later. Each bridge already answers for itself when there is no store -- Android returns null and no-ops, iOS checks its own port flag, the simulation reads preferences -- so the facade has nothing to add. Removing it went wrong once more on the way. The edit matched `if (!b.isSyncedStoreSupported())`, and remove() guards the positive way round, so it survived -- and the assertion meant to catch that was written against the same shape it had assumed. The test caught it. Every remaining reference is now enumerated rather than pattern-matched: exactly one, in isSupported() itself. commit() was still void, so a checkpoint the storage refused ended there in silence: the restore reported no failure, the no-argument restore() released the slot, and a pending publish could erase the relay's copy of a state with no durable copy anywhere and no acknowledgement. It returns whether the arrival is settled, and both call sites read it. And an automatic restore that failed now parks the arrival, as the deferred listener branch beside it already did. pollFinished() has queued a publisher behind that dispatch, so an empty slot let the pending local checkpoint replace the relay's only copy of the state that had just failed -- destroying the retry the failure is kept for. restore(state) always knew; this call site discarded the answer. The new facade test drives an unsupported-but-working bridge, which is the iOS shape exactly, so it fails again if any of the three layers takes a gate back. Full module 6253/6253, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 36 +++++-- .../continuity/sync/SyncedStore.java | 25 ++--- .../continuity/LocalContinuityTest.java | 97 +++++++++++++++++++ 3 files changed, 138 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 89cd6362bab..90f48a36752 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -885,8 +885,10 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // 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. - commit(state, applied, failed); - outFailed[0] = failed; + // The COMMIT's answer, not just `failed`. A checkpoint storage refused leaves no + // durable copy and no acknowledgement, which is the same "keep holding it" as a + // provider that threw -- and reading only `failed` here let the slot go anyway. + outFailed[0] = !commit(state, applied, failed); return false; } // Applying a state is not the user navigating, and the difference is not cosmetic. The @@ -919,8 +921,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // payload already worked on this one. failed = true; } - commit(state, applied || shown, failed); - outFailed[0] = failed; + outFailed[0] = !commit(state, applied || shown, failed); return shown; } @@ -1189,18 +1190,24 @@ private static List currentRoutes() { /// mark only re-prompts the user on every launch for ever. That distinction is why there are /// two flags and not one -- a single "did it apply" answers both questions and gets one of /// them wrong whichever way it is set. - private static void commit(AppState state, boolean applied, boolean failed) { + /// #### Returns + /// + /// true when the arrival is SETTLED -- marked handled and, where something applied, stored. + /// False is the caller's signal to keep holding it: void was the bug, because a checkpoint + /// that storage refused ended here silently and restore() then released the slot for a state + /// with no durable copy anywhere and no acknowledgement. + private static boolean commit(AppState state, boolean applied, boolean failed) { if (failed) { // An attempt was made and it did not work: a provider that threw, or routes that // could not be rebuilt. The relay's copy has to stay on offer for a launch that can // use it, so nothing is marked. - return; + return false; } if (applied && !persist(state)) { // Tried to store it and could not. The relay's copy is now the only one that exists, // so it must go on being offered: acknowledging here loses the state in both // directions at once. - return; + return false; } if (applied) { // A checkpoint queued before this restore describes a screen that no longer exists. @@ -1217,6 +1224,7 @@ private static void commit(AppState state, boolean applied, boolean failed) { publishRequested = false; } noteActedOn(state); + return true; } /// Writes the checkpoint, and says whether it got there. @@ -1701,7 +1709,19 @@ private static void dispatch(AppState state) { // // The in-memory mark still goes in at admission, which is what dedups within a run. // Durability is a separate question and has one owner. - restore(state); + boolean[] restoreFailed = new boolean[1]; + restore(state, restoreFailed); + if (restoreFailed[0]) { + // PARKED, exactly as a deferred arrival is. An automatic restore that failed is + // an arrival nobody has dealt with: pollFinished() has already queued a publisher + // behind this dispatch, and with the slot empty it posts the pending local + // checkpoint over the relay's only copy of the state that just failed -- so the + // retry this failure is kept for has nothing left to retry. + // + // The answer was thrown away here. restore(state) has always known the + // difference; this call site simply did not ask. + parked = state; + } } else { parked = state; } diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 38ab21e9645..9a0a542ca69 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -95,6 +95,18 @@ public static boolean isSupported() { /// /// true when the store holds the value afterwards; false when there is no store, or the /// platform would not take it -- a key count or a size past what it allows + /// Not gated on isSupported(), and that is the THIRD layer this was wrong in. + /// + /// isSupported() asks whether this build has a store that follows the user between devices, + /// which is the right question for an application deciding whether to offer the feature and + /// the wrong gate for the calls themselves. On iOS the store is a LOCAL persistent one whose + /// cloud propagation is asynchronous, so reads and writes work and reach other devices later. + /// + /// The gate was on all three of IOSNative.m, IOSContinuityBridge and here. Removing it from + /// the first two changed nothing, because this one still made every call unreachable -- a fix + /// verified at one layer and dead at the next. Each bridge answers for itself when there is + /// no store: the Android one returns null and no-ops, the iOS one checks its own port flag, + /// and the simulation reads local preferences. public static boolean put(String key, String value) { requireKey(key); if (value == null) { @@ -106,9 +118,6 @@ public static boolean put(String key, String value) { return false; } try { - if (!b.isSyncedStoreSupported()) { - return false; - } return b.syncedStorePut(key, value); } catch (Throwable t) { Log.e(t); @@ -136,9 +145,6 @@ public static String get(String key, String def) { return def; } try { - if (!b.isSyncedStoreSupported()) { - return def; - } String value = b.syncedStoreGet(key); return value == null ? def : value; } catch (Throwable t) { @@ -159,9 +165,7 @@ public static void remove(String key) { return; } try { - if (b.isSyncedStoreSupported()) { - b.syncedStoreRemove(key); - } + b.syncedStoreRemove(key); } catch (Throwable t) { Log.e(t); } @@ -178,9 +182,6 @@ public static String[] keys() { 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) { 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 index 5630b9ba0be..60cdab4c84e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2716,6 +2716,103 @@ public Form dispatch(String path) { } } + /** + * An automatic restore that FAILED leaves the arrival parked. + * + *

pollFinished() has already queued a publisher behind this dispatch, so with the slot + * empty it posts the pending local checkpoint over the relay's only copy of the state that + * just failed -- and the retry the failure is kept for has nothing left to retry. The + * deferred-listener branch beside it got this; the automatic one threw the answer away.

+ */ + @EdtTest + public void anAutomaticRestoreThatFailedKeepsTheArrivalParked() { + Continuity.setAutoRestore(true); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + throw new IllegalStateException("the dependency this needs is not up yet"); + } + }); + + Continuity.deliver(fromElsewhere("work worth keeping", 61L)); + flushSerialCalls(); + + AppState offered = Continuity.getRestorableState(); + assertNotNull(offered, + "an automatic restore that threw dropped the arrival, so a queued checkpoint can " + + "replace the relay's only copy of it"); + assertEquals(61L, offered.getSequence(), "a different state is on offer"); + } + + /** + * A checkpoint the storage refused keeps the parked state too. + * + *

commit() used to be void, so a refused write ended there silently: the restore reported + * no failure, the slot was released, and a pending publish could erase the relay copy of a + * state with no durable copy anywhere and no acknowledgement.

+ */ + @EdtTest + public void aRestoreWhoseCheckpointCannotBeStoredKeepsTheParkedState() { + Continuity.setAutoRestore(false); + Continuity.setStateProvider(new RecordingProvider()); + + AppState arrival = fromElsewhere("unstorable", 62L); + arrival.setRoutes(new ArrayList()); + Continuity.deliver(arrival); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), "the arrival never parked"); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.STORAGE_KEY)); + try { + Continuity.restore(); + } finally { + Storage.setStorageInstance(original); + } + + AppState still = Continuity.getRestorableState(); + assertNotNull(still, + "a restore whose checkpoint storage refused released the slot, so nothing " + + "durable holds this state and a queued publish can erase the relay's " + + "copy of it"); + assertEquals(62L, still.getSequence(), "a different state is on offer"); + } + + /** + * The synced store reaches the platform without consulting the entitlement probe. + * + *

The gate was on three layers -- the native store, the iOS bridge and the public facade -- + * and removing it from the first two changed nothing, because the third still made every call + * unreachable. This drives the facade, which is the layer an application actually touches, and + * is the check that was missing when the first two "fixes" were called done.

+ */ + @EdtTest + public void theSyncedStoreFacadeReachesTheBridgeWithoutTheProbe() { + // A bridge that reports the feature UNSUPPORTED while still holding values, which is + // exactly the iOS shape the fix is about: the entitlement probe has not succeeded, and + // the store underneath is a local one that works anyway. + Continuity.setBridge(new LocalContinuityBridge() { + @Override + public boolean isSyncedStoreSupported() { + return false; + } + }); + + assertFalse(SyncedStore.isSupported(), + "the fixture says the probe succeeded, so this proves nothing about the gate"); + assertTrue(SyncedStore.put("draft", "half a sentence"), + "the facade refused a write on a bridge that would have taken it"); + assertEquals("half a sentence", SyncedStore.get("draft", "nothing"), + "the facade refused to read a value the bridge is holding"); + + SyncedStore.remove("draft"); + assertEquals("nothing", SyncedStore.get("draft", "nothing"), + "the facade did not reach the bridge to remove the key"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From e66b57b2d254fb16082b4a75b6cc3b3462e9fe83 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:14:29 +0300 Subject: [PATCH 059/140] Continuity: a failed read holds until one succeeds, and a newer state frees its predecessor The failed-fetch hold lasted until the next checkpoint, not until a read that worked. pollFinished() clears `polling` before returning, and startPublisher()'s only fetch guard was that flag -- so the very next checkpoint published over a relay document this device had never managed to read. The comment beside it said "anything owed waits for a read that succeeds", which is what the code was meant to do and not what it did. fetchUnread now outlives the poll. A checkpoint arriving while it is set starts a FRESH read rather than publishing, and rather than refusing: an application that goes on working while the network is down must not stop publishing for the life of the process, so the state stays owed and whichever read succeeds releases it. Cleared with the relay session, since a new session has read nothing and owes nothing. And completing a state now frees an older one parked from the same origin. A device can have two in flight -- a continuation and a relay poll routinely carry different sequences -- so N could sit parked while N+1 was accepted and restored. The identity comparison left it there: still offered by getRestorableState(), so restoring it walked the user and the stored checkpoint BACKWARDS, and the publication hold never lifted. Supersession is the rule the tombstone path already used, and it is applied at both call sites rather than only the one that was reported, because the reasoning is identical. That made isSameState dead, and SpotBugs' UPM_UNCALLED_PRIVATE_METHOD is a hard failure on a zero-findings gate, so it is removed. Worth recording how close that came to shipping: the module suite was 6255/6255 GREEN while verify was red. Tests and static analysis fail independently, and stopping at the test count would have pushed it. The PMD report was absent in that same run, because verify died before writing it. Reported as ABSENT rather than as zero findings -- an absent report is not a clean one, and reading it as clean is how a real finding gets declared fine. Full module 6255/6255, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0 from a report that exists, copyright, control-character and cast gates clean. Both fixes proven by reverting them alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 67 +++++++++++---- .../continuity/LocalContinuityTest.java | 86 +++++++++++++++++++ 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 90f48a36752..de9b6827e82 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -789,7 +789,7 @@ public static boolean restore() { // `failed` is the distinction `shown` cannot make. False means both "there was no form to // show, and that is success" and "this did not work", which need opposite handling here -- // the same conflation that put two flags in capture() and in checkpoint(). - if (isSameState(parked, state)) { + if (supersedesParked(state)) { parked = null; // The slot is what holds a publication back; the decision has been made, so anything // waiting on it can go out now. @@ -1013,6 +1013,11 @@ private static void pollFinished(AppState fetched, boolean fetchFailed, int sess return; } polling = false; + // Recorded, because `polling` stops being true the moment this returns and the hold below + // would then last only until the next checkpoint -- which is not what "anything owed + // waits for a read that succeeds" says. The comment was making a promise the code kept + // for exactly one caller. + fetchUnread = fetchFailed; boolean admitted = false; if (fetched != null) { admit(fetched); @@ -1146,6 +1151,9 @@ private static void endRelaySession() { polling = false; pollAgain = false; publishRequested = false; + // A new session has read nothing yet, and owes nothing either: the slot was just emptied. + // Carrying a failed read across would make the next checkpoint poll for no reason. + fetchUnread = false; } // ------------------------------------------------------------------ @@ -1344,6 +1352,14 @@ private static void clearContinuation() { /// True while a relay fetch is out; `pollAgain` records a poll asked for during one. private static boolean polling; + /// Whether the last relay read FAILED, so what the document holds is unknown. + /// + /// Publishing replaces the relay's single document, and that is only safe because a poll + /// established what was there. A read that timed out established nothing, so anything owed + /// waits -- not for the next checkpoint, which is where the protection used to end, but for a + /// read that succeeds. + private static boolean fetchUnread; + private static boolean pollAgain; /// True when a publisher was wanted while one was already out. @@ -1408,6 +1424,19 @@ private static void startPublisher() { if (pendingPublish == null) { return; } + if (fetchUnread) { + // The last read of the relay FAILED, so what the document holds is unknown -- and + // writing over it is only safe because a poll established that. A timeout establishes + // nothing, and the protection used to end at the next checkpoint rather than at a + // successful read. + // + // A fresh poll rather than a refusal: an application that goes on working while the + // network is down must not stop publishing for the rest of the process, so the state + // stays owed and the read is retried. Whichever poll succeeds releases it. + publishRequested = true; + startPoll(); + return; + } if (polling) { // A GET is outstanding. The relay holds ONE document per user, so a POST that lands // before the answer overwrites the other device's state -- and the GET then reads back @@ -1829,20 +1858,6 @@ private static String loadDeviceId() { } } - /// 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(); @@ -1869,7 +1884,7 @@ private static void noteActedOn(AppState state) { recordDurable(from, seq); } } - if (isSameState(parked, state)) { + if (supersedesParked(state)) { // The application has dealt with this arrival -- acknowledge() is the documented way // to decline one -- so the slot must not go on offering it through // getRestorableState(), and must not go on holding a checkpoint back either. The @@ -1907,6 +1922,26 @@ static boolean isInstalledRelay(StateRelay r) { return r != null && r == relay; //NOPMD CompareObjectsWithEquals } + /// Whether completing `state` also finishes whatever is parked. + /// + /// Not identity. A device can have two states in flight -- a continuation and a relay poll + /// routinely carry different sequences -- so sequence N can be parked while N+1 from the same + /// origin is admitted and restored. Asking only "is this the same state" left N in the slot: + /// getRestorableState() went on offering work the origin had already moved past, restoring it + /// would have walked the user and the stored checkpoint BACKWARDS, and the publication hold + /// never lifted. + /// + /// The same rule the tombstone path applies, for the same reason: an origin telling us where + /// it is now settles everything of its own that came before. A state from a DIFFERENT origin + /// settles nothing here, which is what keeps this device's own checkpoint from releasing a + /// hold that belongs to somebody else's arrival. + private static boolean supersedesParked(AppState state) { + AppState waiting = parked; + return waiting != null + && waiting.getDeviceId().equals(state.getDeviceId()) + && waiting.getSequence() <= state.getSequence(); + } + /// Test seam: parks a state, as a cold-launch arrival with no form yet does. static void parkForTest(AppState state) { parked = state; 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 index 60cdab4c84e..99bea4489cd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2813,6 +2813,92 @@ public boolean isSyncedStoreSupported() { "the facade did not reach the bridge to remove the key"); } + /** + * A failed relay read holds the publication until a read SUCCEEDS, not until the next + * checkpoint. + * + *

Writing over the relay's single document is only safe because a poll established what + * was there; a timeout establishes nothing. The hold used to end the moment pollFinished() + * cleared {@code polling}, so the very next checkpoint published over a document this device + * had never managed to read.

+ */ + @EdtTest + public void aFailedReadHoldsThePublicationUntilAReadSucceeds() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + + final java.util.concurrent.atomic.AtomicInteger fetches = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean readWorks = + new java.util.concurrent.atomic.AtomicBoolean(false); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() throws java.io.IOException { + fetches.incrementAndGet(); + if (!readWorks.get()) { + throw new java.io.IOException("no network"); + } + return null; + } + }); + pause(300L); + flushSerialCalls(); + assertTrue(fetches.get() > 0, "the relay was never read, so no read has failed yet"); + published.clear(); + + // The checkpoint that used to go out on the strength of a read that never happened. + provider.saved.put("after", "work done while offline"); + Continuity.checkpoint(); + pause(300L); + flushSerialCalls(); + assertTrue(published.isEmpty(), + "a checkpoint was published over a relay document this device has never managed " + + "to read: published=" + published.size()); + + // And it is a hold, not a refusal: the work is still owed, and a read that succeeds + // releases it. Without that this test would pass on a relay that had simply stopped. + readWorks.set(true); + Continuity.checkpoint(); + pause(500L); + flushSerialCalls(); + assertFalse(published.isEmpty(), + "the state stayed owed for ever once a read had failed, so this device never " + + "publishes again for the life of the process"); + } + + /** + * Completing a newer state from an origin releases an older one parked from the same origin. + * + *

A device can have two states in flight -- a continuation and a relay poll routinely carry + * different sequences -- so N can be parked while N+1 is admitted and restored. An identity + * comparison left N in the slot: it was still offered, restoring it would have walked the user + * and the stored checkpoint backwards, and the publication hold never lifted.

+ */ + @EdtTest + public void completingANewerStateReleasesAnOlderOneFromTheSameOrigin() { + Continuity.setAutoRestore(false); + Continuity.setStateProvider(new RecordingProvider()); + + Continuity.deliver(fromElsewhere("the older screen", 70L)); + flushSerialCalls(); + AppState older = Continuity.getRestorableState(); + assertNotNull(older, "nothing parked, so there is no predecessor to strand"); + assertEquals(70L, older.getSequence(), "a different state parked"); + + // The same origin, further along. Acknowledging it is the origin saying where it is now. + Continuity.acknowledge(fromElsewhere("the newer screen", 71L)); + flushSerialCalls(); + + AppState stranded = Continuity.getRestorableState(); + assertTrue(stranded == null || stranded.getSequence() != 70L, + "the superseded state is still on offer, so restoring it walks the user backwards " + + "and the publication hold never lifts"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 8f7ba45b7810b2bcc7d7dd5b7ade6f8acdb04eca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:37:28 +0300 Subject: [PATCH 060/140] Continuity: a local write reports the truth, and an acknowledgement outlives its mark The native put computed `synced && readback == value`, which was the last place the entitlement probe still decided a local write's fate. The comment two lines above already said the readback is what can be established here, and then ANDed in something else: SyncedStore.put documents "true when the store holds the value afterwards", which IS the readback, while synchronize answers whether this build is entitled to a store that follows the user -- the question continuitySyncedStoreSupported exists for. A transient NO therefore reported failure for a value the store was holding and would have propagated later. synchronize is still called, because it moves propagation along. It is simply not part of the answer any more. That is the fourth layer of this same confusion and the last of them. The second is two of my own fixes colliding. isAlreadyActedOn read durableSeen to decide whether a listener had already acknowledged an arrival -- and that map is bounded by what one stored string can hold, so trimToWritable() evicts an entry the instant it goes in when the device id is large enough. It answered "not acted on" a microsecond after acknowledge() returned and parked a finished state, which left it on offer with every relay checkpoint held behind it. Neither fix is wrong alone. The map answers what the NEXT LAUNCH will know; this question is what THIS PROCESS has already done, and they are now separate. One slot is the right size for it: the only reader asks immediately after the listeners for the state it is about to park. It is cleared by reset() and by clear(). Both were added because the comment claimed reset() did it -- writing a comment ahead of the code is the exact gap this branch keeps finding, and it does not get an exception for being mine. The test needed the boundary exactly: an id longer than 65535 bytes is refused by setDeviceId before the test starts, and a shorter one keeps its mark and proves nothing. At the limit the id is legal and its MARK -- id plus separators plus sequence -- is not. Probing confirms the pair covers different ground: with the in-process record removed the long-id test fails and the short-id one still passes. Full module 6256/6256, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and native-signature gates clean. The native change syntax-checked against the real iOS SDK for arm64: six diagnostics, all pre-existing and identical on master, none in this region. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 34 ++++++++++++++ Ports/iOSPort/nativeSources/IOSNative.m | 22 +++++---- .../continuity/LocalContinuityTest.java | 46 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index de9b6827e82..53b5442bd52 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1093,6 +1093,10 @@ public static void clear() { endRelaySession(); lastSeen.clear(); durableSeen.clear(); + // Forgotten beside the marks it belongs with. It records what THIS account's session + // dealt with, and keeping it would let a state re-delivered to the next account be + // treated as already handled. + lastCompleted = null; // The durable copy as well. 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. @@ -1154,6 +1158,10 @@ private static void endRelaySession() { // A new session has read nothing yet, and owes nothing either: the slot was just emptied. // Carrying a failed read across would make the next checkpoint poll for no reason. fetchUnread = false; + // Deliberately NOT cleared here. Ending a relay session says nothing about what the + // application has already dealt with, and forgetting it would let an arrival that was + // acknowledged before the session changed be parked again afterwards. reset() clears it. + } // ------------------------------------------------------------------ @@ -1360,6 +1368,15 @@ private static void clearContinuation() { /// read that succeeds. private static boolean fetchUnread; + /// The most recently completed arrival, as an in-process fact rather than a stored one. + /// + /// One slot is the right size: the only reader asks immediately after the listeners for the + /// state it is about to park, so the thing that can have completed in between is that state. + /// It exists because the durable map is bounded by what a single stored string can hold, and + /// an entry can be evicted on the way in -- which is a statement about persistence, not about + /// whether the application has dealt with the arrival. + private static AppState lastCompleted; + private static boolean pollAgain; /// True when a publisher was wanted while one was already out. @@ -1769,6 +1786,19 @@ private static void dispatch(AppState state) { /// write to storage succeeded, and what is being asked here is what this process has done, not /// what survived to disk. private static boolean isAlreadyActedOn(AppState state) { + // The in-process record FIRST, because durableSeen is bounded by what one stored string + // can hold and trimToWritable() can evict an entry the moment it goes in -- a device id + // long enough to blow the budget on its own does exactly that. Asking only the map then + // said a state acknowledged a microsecond earlier had not been acted on, and parked it: + // still offered, with every relay checkpoint held behind work that was already finished. + // + // Two fixes of mine meeting. Neither is wrong on its own; the map answers "what will the + // next launch know", and this question is "what has this process already done". + AppState done = lastCompleted; + if (done != null && done.getDeviceId().equals(state.getDeviceId()) + && done.getSequence() >= state.getSequence()) { + return true; + } Long mark = durableSeen.get(state.getDeviceId()); return mark != null && mark.longValue() >= state.getSequence(); } @@ -1893,6 +1923,9 @@ private static void noteActedOn(AppState state) { parked = null; startPublisher(); } + // Recorded before the write, because it is not about the write. What this process has + // dealt with is true whether or not the mark reaches storage or survives the size budget. + lastCompleted = state; // ALWAYS, not only when a map moved. The condition this replaced was written when the // durable copy tracked memory exactly; it does not, and by the time anything calls this // memory already holds the entry, so "unchanged" meant "write nothing" and both @@ -2351,6 +2384,7 @@ static void reset() { waitingForWindow = false; applyingRestore = false; storeCallbackInstalled = false; + lastCompleted = null; } /// The store notification, as a constant rather than an anonymous class per callback. diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index d4ca5514eae..35cae8281ac 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20794,17 +20794,19 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la 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. + // Asked for, not waited on and NOT reported. The system syncs on its own schedule and this + // only moves it along; its answer is about the STORE -- whether this build is entitled to one + // that follows the user -- which continuitySyncedStoreSupported reports and which this call + // is not being asked. ANDing it into the result here was the last place the entitlement probe + // still decided the fate of a local write: a transient NO made put() report failure for a + // value the store was holding and would have propagated later. + [store synchronize]; + // The READBACK is the answer, and it is exactly what SyncedStore.put documents -- "true when + // the store holds the value afterwards". It is also the only part that can be established + // from in here: a store at its key or size limit drops the write while reporting nothing, and + // whether iCloud goes on to propagate it is not knowable from inside this call. NSString *back = [store stringForKey:k]; - if (synced && back != nil && [back isEqualToString:v]) { + if (back != nil && [back isEqualToString:v]) { result = JAVA_TRUE; } POOL_END(); 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 index 99bea4489cd..6b88e382d32 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2899,6 +2899,52 @@ public void completingANewerStateReleasesAnOlderOneFromTheSameOrigin() { + "and the publication hold never lifts"); } + /** + * A synchronous acknowledgement survives a device id too long to keep a durable mark for. + * + *

Two fixes meeting. The durable map is bounded by what one stored string can hold, so + * trimToWritable() can evict an entry the moment it goes in -- an id long enough to blow that + * budget on its own does exactly that. Asking only that map whether the arrival had been acted + * on then said no a microsecond after acknowledge() returned, and parked a finished state: + * still offered, with every relay checkpoint held behind it.

+ * + *

Neither fix is wrong alone. The map answers what the next launch will know; this question + * is what this process has already done.

+ */ + @EdtTest + public void aSynchronousAcknowledgementSurvivesAnIdTooLongToStore() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + Continuity.acknowledge(state); + return false; + } + }); + + // Exactly at the limit an AppState accepts for a device id, which is one byte per + // character here -- so the id is legal, and the MARK for it is not: the entry costs the + // id plus its separators and sequence, which is past what a single stored string holds, + // and the size budget evicts it the instant it goes in. A shorter id would fit and prove + // nothing; a longer one is refused by setDeviceId before this test begins, which is how + // the first version of it failed. + StringBuilder id = new StringBuilder("device-"); + while (id.length() < 65535) { + id.append('d'); + } + Map payload = new HashMap(); + payload.put("note", "handled in the callback"); + Continuity.deliver(new AppState() + .setPayload(payload) + .setDeviceId(id.toString()) + .setSequence(81L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertNull(Continuity.getRestorableState(), + "an arrival the listener had acknowledged was parked because its durable mark did " + + "not fit, so it stays on offer and holds every checkpoint off the relay"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 855a2ff47bbb49bd3268fcc7b1dc46780cef83d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:56:52 +0300 Subject: [PATCH 061/140] Continuity: the simulated synced store stops confirming writes from its own cache Preferences.set() fills an in-memory table whose save() DISCARDS Storage.writeObject()'s result, and Preferences.get() reads that table. The simulation's read-back therefore consulted the cache it had just written and agreed with itself, so put() reported success for a value that is gone at the next launch. An oversized value makes it deterministic rather than a full-disk curiosity. That is the same trap the sequence counter and the delivery marks were moved off Preferences to escape, earlier on this branch. The synced store was left behind, and it is the worst place to leave it: the simulator and the desktop app are what applications are developed against, so a store that confirms writes it did not make teaches an application something false about the device. It persists through Storage now, with the write checked. The index write is checked too and its failure reported, because a value stored under a key the index has lost is findable by name and invisible to keys() -- a caller told its write succeeded has been told something only half true. The Preferences import went with it, which the forbidden-PMD list would have caught, and the three doc comments naming Preferences as the backing store are corrected rather than left to mislead the next reader. The first probe of this was UNFAITHFUL and nearly accepted: it reverted put() to Preferences and left get() reading Storage, so the test failed at the control rather than at the refusal -- proof that the test notices something, not that it catches this. Reverting both halves puts the failure on the refusal assertion with the control passing, which is the only version that means anything. Full module 6257/6257, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/LocalContinuityBridge.java | 85 ++++++++++++++++--- .../continuity/LocalContinuityTest.java | 29 +++++++ 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index ec7c651bd4d..17603a6e702 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -25,7 +25,7 @@ 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 java.util.ArrayList; import java.util.HashMap; @@ -43,13 +43,19 @@ /// 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 +/// within one machine: it is backed by `com.codename1.io.Storage`, so it survives a simulator /// restart the way the platform store survives a device one. +/// +/// Storage rather than Preferences, and not as a detail. Preferences.set() fills an in-memory +/// table whose save() discards the write's result, and Preferences.get() reads that table -- so a +/// value that never reached the disk reads back correctly right up until the next launch, and a +/// simulation that reported success for it would be teaching an application something false about +/// the device. public class LocalContinuityBridge implements ContinuityBridge { - /// Prefix for the simulated synced store's keys inside `Preferences`. + /// Prefix for the simulated synced store's keys inside `Storage`. private static final String PREFIX = "CN1$SyncedStore$"; - /// The list of keys, kept beside them because `Preferences` cannot be enumerated. + /// The list of keys, kept beside them because the store is addressed by name only. private static final String INDEX = "CN1$SyncedStoreKeys"; // EDT-owned. Everything here runs on the Codename One event thread: the framework calls in @@ -167,25 +173,71 @@ public boolean isSyncedStoreSupported() { @Override public boolean syncedStorePut(String key, String value) { - Preferences.set(PREFIX + key, value); + // Storage, not Preferences, and the difference is the whole point of this method's + // return. Preferences.set() fills an in-memory table and its save() DISCARDS + // Storage.writeObject()'s result, so a write that never reached the disk leaves the new + // value in that table -- and Preferences.get() reads the table. The read-back below used + // to consult the cache it had just written and agree with itself, so put() reported + // success for a value that disappears when the simulator restarts. An oversized value + // makes it deterministic rather than a full-disk curiosity. + // + // Same correction the sequence counter and the delivery marks already needed. The + // simulation has to answer the question the device answers -- is the value there now -- + // and only a checked write can. + if (!write(PREFIX + key, value)) { + return false; + } List keys = indexKeys(); if (!keys.contains(key)) { keys.add(key); - writeIndex(keys); + if (!writeIndex(keys)) { + // The value is stored and the index is not, so keys() would not list it. Reported + // rather than hidden: a caller told the write succeeded expects to find it again + // by enumeration as well as by name. + return false; + } + } + return true; + } + + /// Writes one value, reporting whether it actually reached storage. + private boolean write(String name, String value) { + try { + return Storage.getInstance().writeObject(name, value); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + /// Reads one value, or null for anything that is not a stored string. + private String read(String name) { + try { + if (!Storage.getInstance().exists(name)) { + return null; + } + Object o = Storage.getInstance().readObject(name); + // instanceof rather than a cast: a failed cast does not throw on the iOS virtual + // machine, and this class is compiled into every port. + return o instanceof String ? (String) o : null; + } catch (Throwable t) { + Log.e(t); + return null; } - // Read back rather than assume, so the simulation answers the same question the device - // does: is the value there now? - return value.equals(Preferences.get(PREFIX + key, null)); } @Override public String syncedStoreGet(String key) { - return Preferences.get(PREFIX + key, null); + return read(PREFIX + key); } @Override public void syncedStoreRemove(String key) { - Preferences.delete(PREFIX + key); + try { + Storage.getInstance().deleteStorageFile(PREFIX + key); + } catch (Throwable t) { + Log.e(t); + } List keys = indexKeys(); if (keys.remove(key)) { writeIndex(keys); @@ -214,7 +266,7 @@ public void simulateStoreChange() { private List indexKeys() { List keys = new ArrayList(); - String raw = Preferences.get(INDEX, ""); + String raw = read(INDEX); if (raw == null || raw.length() == 0) { return keys; } @@ -276,7 +328,12 @@ private static String unescapeIndexEntry(String entry) { return sb.toString(); } - private void writeIndex(List keys) { + /// Writes the key index, reporting whether it reached storage. + /// + /// The answer is used rather than logged: a value stored under a key the index has lost is + /// findable by name and invisible to keys(), and a caller told its write succeeded has been + /// told something that is only half true. + private boolean writeIndex(List keys) { StringBuilder sb = new StringBuilder(); for (String key : keys) { if (sb.length() > 0) { @@ -284,6 +341,6 @@ private void writeIndex(List keys) { } sb.append(escapeIndexEntry(key)); } - Preferences.set(INDEX, sb.toString()); + return write(INDEX, sb.toString()); } } 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 index 6b88e382d32..e4e8d55ae25 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2945,6 +2945,35 @@ public boolean stateReceived(AppState state) { + "not fit, so it stays on offer and holds every checkpoint off the relay"); } + /** + * The simulated synced store reports a write that did not reach storage. + * + *

It used to persist through {@code Preferences}, whose {@code set()} fills an in-memory + * table and whose {@code save()} discards the write's result -- and whose {@code get()} reads + * that table. The read-back therefore consulted the cache it had just written and agreed with + * itself, so put() reported success for a value that vanishes at the next launch. The + * simulator and the desktop app are what applications develop against, so this taught them + * something false about the device.

+ */ + @EdtTest + public void theSimulatedStoreReportsAWriteThatDidNotReachStorage() { + // The control first: with storage working the same call must succeed, or an + // unconditional false would satisfy the assertion below and break the store. + assertTrue(SyncedStore.put("draft", "half a sentence"), + "the write failed with storage working, so the refusal below proves nothing"); + assertEquals("half a sentence", SyncedStore.get("draft", "nothing")); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingStorage()); + try { + assertFalse(SyncedStore.put("draft", "a longer sentence"), + "a write storage refused was reported as success, so the value is gone at the " + + "next launch and the application was told it was saved"); + } finally { + Storage.setStorageInstance(original); + } + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 3b723b27233487f1c4509895f2ad908918ba87bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:17:57 +0300 Subject: [PATCH 062/140] Continuity: a restore withdraws the activity it made stale, not just the queued publish Continuity has three outbound channels -- the relay, the platform activity, and the local checkpoint -- and "the screen this described is gone" has to reach all of them. Two rounds ago a restore learned to drop the queued relay publish and nothing else, which fixed the channel that was reported and left its sibling saying the opposite. The platform activity stays current until something replaces or withdraws it, and applyingRestore suppresses the checkpoint the rebuilt route stack would have triggered. So after restoring somebody else's state this device went on offering the PRE-restore screen to every Apple device around it until the user happened to navigate again, and a third device could continue into a screen this one had already left. Withdrawn rather than re-advertised with the restored state. The device that sent it is most likely still offering it, and two devices advertising the same continuation is a worse answer than a short gap in which nothing false is offered; the user's next action advertises the truth. Full module 6258/6258, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Proven by reverting the withdrawal alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 13 ++++++++ .../continuity/LocalContinuityTest.java | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 53b5442bd52..ceaf0f28cbe 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1238,6 +1238,19 @@ private static boolean commit(AppState state, boolean applied, boolean failed) { // prevent. Nothing goes out until the user does something new. pendingPublish = null; publishRequested = false; + // The ADVERTISED activity is stale in exactly the same way, and dropping only the + // queued publish left half the job done. The platform activity stays current until + // something replaces or withdraws it, and applyingRestore suppresses the checkpoint + // the rebuilt route stack would otherwise have triggered -- so this device went on + // offering the pre-restore screen to every Apple device around it until the user + // happened to navigate again, and a third device could continue into a screen this + // one had already moved off. + // + // Withdrawn rather than re-advertised with the restored state. The device this state + // CAME FROM is most likely still offering it, and two devices advertising the same + // continuation is a worse answer than a short gap: nothing false is offered, and the + // user's next action advertises the truth. + clearContinuation(); } noteActedOn(state); return true; 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 index e4e8d55ae25..5087750a770 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2974,6 +2974,38 @@ public void theSimulatedStoreReportsAWriteThatDidNotReachStorage() { } } + /** + * Restoring a foreign state withdraws the activity this device was advertising. + * + *

The platform activity stays current until something replaces or withdraws it, and + * {@code applyingRestore} suppresses the checkpoint the rebuilt route stack would have + * triggered -- so the pre-restore screen went on being offered to every Apple device around + * until the user next navigated, and a third device could continue into a screen this one had + * already moved off.

+ */ + @EdtTest + public void restoringAForeignStateWithdrawsTheStaleAdvertisement() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("screen", "the one this device was on"); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + + // What this device is advertising before anything arrives. + Continuity.checkpoint(); + flushSerialCalls(); + assertNotNull(bridge.getPublishedType(), + "nothing was advertised, so there is no stale activity for the restore to leave"); + + Continuity.deliver(fromElsewhere("what the other device was doing", 91L)); + flushSerialCalls(); + assertNotNull(provider.restored, + "the arrival was not applied, so this test is about nothing"); + + assertNull(bridge.getPublishedType(), + "the pre-restore activity is still advertised after restoring somebody else's " + + "state, so a third device continues into a screen this one has left"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 60242ba56882ccf5c6c4ce8bd754a8fd2880f9dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:36:38 +0300 Subject: [PATCH 063/140] Continuity: the recovery read stops overlapping the read it recovers from The fetchUnread branch added last round was placed ABOVE the one-fetch-at-a-time guard, so a second checkpoint launched a second GET while the recovery read was still in flight. That is exactly what the guard three lines below it forbids, and its comment already spelled out why: two overlapping reads can return different documents -- the relay holds one per user and the other device may replace it between them -- and nothing downstream re-orders the answers, so whichever finished first cleared `polling` and could release a publish over a document the other had not seen. A recovery read is still a read. The branch now sits below the guard, and startPoll() enforces the invariant itself so placement cannot break it again: a caller asking for a read while one is outstanding gets pollAgain, the same answer the caller-side guard gives. Both, not either, and the probing is why that distinction is recorded here. Removing one guard left the test passing, because the other still held -- so neither probe proved anything on its own. Only removing BOTH, which is the code exactly as it was, failed the test. A belt-and-braces fix has to be reverted whole; stripping one strap at a time would have let this be called proven on evidence that was not there. Full module 6259/6259, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 31 +++++-- .../continuity/LocalContinuityTest.java | 89 +++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index ceaf0f28cbe..f507668b79a 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -971,6 +971,18 @@ private static void startPoll() { if (r == null || !Display.isInitialized()) { return; } + if (polling) { + // The one-fetch-at-a-time invariant, enforced where the fetch is STARTED rather than + // only at the call sites that remember to ask. Two overlapping GETs can return + // different documents -- the relay holds one per user and the other device may + // replace it between them -- and nothing downstream re-orders the answers, so the + // response that left first and arrived second puts the older screen over the newer. + // + // Remembered rather than dropped, exactly as the caller-side guard does: whoever + // wanted this read gets one when the outstanding fetch lands. + pollAgain = true; + return; + } final int session = relaySession; polling = true; Display.getInstance().startThread(new Runnable() { @@ -1454,6 +1466,13 @@ private static void startPublisher() { if (pendingPublish == null) { return; } + if (polling) { + // A GET is outstanding. The relay holds ONE document per user, so a POST that lands + // before the answer overwrites the other device's state -- and the GET then reads back + // our own write, so the remote update is never seen. pollFinished() starts a publisher + // when the fetch is done. + return; + } if (fetchUnread) { // The last read of the relay FAILED, so what the document holds is unknown -- and // writing over it is only safe because a poll established that. A timeout establishes @@ -1463,17 +1482,15 @@ private static void startPublisher() { // A fresh poll rather than a refusal: an application that goes on working while the // network is down must not stop publishing for the rest of the process, so the state // stays owed and the read is retried. Whichever poll succeeds releases it. + // + // BELOW the polling guard, which is where this branch belongs and did not start. A + // recovery read is still a read, so putting it first let a second checkpoint launch + // one while the first was in flight -- two overlapping GETs, which is precisely what + // the guard above forbids and for the reason it gives. publishRequested = true; startPoll(); return; } - if (polling) { - // A GET is outstanding. The relay holds ONE document per user, so a POST that lands - // before the answer overwrites the other device's state -- and the GET then reads back - // our own write, so the remote update is never seen. pollFinished() starts a publisher - // when the fetch is done. - return; - } final StateRelay r = relay; final AppState next = pendingPublish; final int session = relaySession; 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 index 5087750a770..ededc50806d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3006,6 +3006,95 @@ public void restoringAForeignStateWithdrawsTheStaleAdvertisement() { + "state, so a third device continues into a screen this one has left"); } + /** + * A recovery read after a failed fetch never overlaps another read. + * + *

The recovery branch was placed ABOVE the one-fetch-at-a-time guard, so a second + * checkpoint launched a second GET while the first was still in flight. Two overlapping reads + * can return different documents -- the relay holds one per user and the other device may + * replace it between them -- and nothing downstream re-orders the answers, so whichever + * finished first cleared {@code polling} and could release the publisher while the other was + * still outstanding.

+ */ + @EdtTest + public void aRecoveryReadNeverOverlapsAnotherRead() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + + final java.util.concurrent.atomic.AtomicInteger live = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicInteger mostAtOnce = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicInteger fetches = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.CountDownLatch inRecovery = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() throws java.io.IOException { + int now = live.incrementAndGet(); + synchronized (mostAtOnce) { + if (now > mostAtOnce.get()) { + mostAtOnce.set(now); + } + } + try { + if (fetches.incrementAndGet() == 1) { + // The first read fails, which is what arms the recovery path. + throw new java.io.IOException("no network"); + } + // Every later read is held open, so a checkpoint arriving now would start a + // second one if anything still let it. + inRecovery.countDown(); + release.await(5L, java.util.concurrent.TimeUnit.SECONDS); + return null; + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return null; + } finally { + live.decrementAndGet(); + } + } + }); + pause(300L); + flushSerialCalls(); + assertEquals(1, fetches.get(), "the first read did not happen, so nothing armed recovery"); + + // Starts the recovery read, which then blocks. + Continuity.checkpoint(); + final boolean[] recovering = new boolean[1]; + awaitOffEdt(new Runnable() { + public void run() { + try { + recovering[0] = inRecovery.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + assertTrue(recovering[0], "no recovery read started, so there is nothing to overlap"); + + // The second checkpoint, while that read is still outstanding. + provider.saved.put("more", "work done meanwhile"); + Continuity.checkpoint(); + pause(300L); + + release.countDown(); + pause(400L); + flushSerialCalls(); + + assertEquals(1, mostAtOnce.get(), + "two relay reads were in flight at once, so whichever answered first could " + + "release a publish over a document the other had not seen"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From a5e5000a9736286229a8911783c313303036b760 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:57:19 +0300 Subject: [PATCH 064/140] Continuity: an arrival with no origin is refused instead of admitted noteActedOn() has always refused a state with no device id, and correctly: every mark is keyed by origin and sequence, so an empty origin is not a weak key but one key shared by every producer that forgot to set one. admit() let such a state through anyway, and the two halves disagreeing is what did the damage -- the state could be restored and never marked, so it was offered again after every restart, and a listener following the documented acknowledge() path left it parked for the life of the process with relay publication held behind it. Refused at admission, which is where deduplication is decided. Nothing this framework produces is anonymous: capture() always sets the id, so what reaches here is a custom StateRelay building a state by hand or a relay document with no "device" member. Said out loud rather than dropped in silence. The integrator is the only person who can fix it, and a state that disappears with no explanation is the kind of thing that costs somebody a day. Full module 6260/6260, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Proven by admitting them again, which fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 20 +++++++++- .../continuity/LocalContinuityTest.java | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index f507668b79a..36a17b5d7a7 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1647,7 +1647,25 @@ private static void admit(final AppState state) { if (!enabled) { return; } - if (getDeviceId().equals(state.getDeviceId())) { + String origin = state.getDeviceId(); + if (origin == null || origin.length() == 0) { + // ANONYMOUS, so it cannot take part in deduplication at all: every mark is keyed by + // origin and sequence, and an empty origin is one key shared by every producer that + // forgot to set one. Admitting it looked harmless and was not -- noteActedOn() has to + // refuse a state with no origin, so nothing was ever marked durably and the same + // state restored again after every restart, while a listener acknowledging it left it + // parked for the life of the process with relay publication held behind it. + // + // Nothing this framework produces is anonymous; capture() always sets the id. What + // reaches here is a custom StateRelay handing back a state it built itself, or a + // relay document with no "device" member, so it is said out loud rather than dropped + // in silence -- the integrator is the only one who can fix it. + Log.p("Continuity: ignoring a state with no device id. A relay must return states " + + "that carry the id of the device they came from, or the same state is " + + "offered again after every restart."); + return; + } + if (getDeviceId().equals(origin)) { // This device's own echo, which a relay returns as a matter of course. return; } 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 index ededc50806d..16eaecba83d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3095,6 +3095,46 @@ public void run() { + "release a publish over a document the other had not seen"); } + /** + * A state with no origin is refused rather than admitted. + * + *

Every mark is keyed by origin and sequence, so an empty origin is a single key shared by + * every producer that forgot to set one -- and noteActedOn() has to refuse such a state, which + * meant nothing was marked durably and the same state was restored again after every restart. + * Worse, a listener following the documented acknowledge() path left it parked for the life of + * the process, with relay publication held behind it.

+ */ + @EdtTest + public void aStateWithNoOriginIsRefused() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final int[] offered = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + offered[0]++; + return false; + } + }); + + Map payload = new HashMap(); + payload.put("note", "from nowhere in particular"); + // No setDeviceId at all, which is what a hand-built relay state or a document with no + // "device" member produces. + Continuity.deliver(new AppState() + .setPayload(payload) + .setSequence(5L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertEquals(0, offered[0], + "a state with no origin reached the application, and nothing can ever mark it " + + "handled"); + assertNull(Continuity.getRestorableState(), + "an origin-less state was parked, so it is offered for ever and every relay " + + "checkpoint waits behind it"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From c4e447a177115e39afe4d5c24f1adeb0a4d15b20 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:28:00 +0300 Subject: [PATCH 065/140] RestStateRelay: background requests stop putting error dialogs in front of the user RequestBuilder sets failSilently only when an error-code handler is registered (RequestBuilder:1046), and failSilently is what gates BOTH of ConnectionRequest's dialogs -- the failure response code (:1971) and the connection exception (:1802). These requests registered none, so a relay answering the DOCUMENTED 404 for "nothing stored yet" showed the user a Retry/Cancel dialog on first run, before this class could read the code and call it an empty relay. Housekeeping the user never asked for was interrupting them, and doing it in the ordinary case rather than an exotic one. The handler is registered in auth(), the single funnel publish() and fetch() both pass through, so neither can regain a dialog by being edited on its own. It does nothing deliberately: getAsString() builds its Response from the request's own code and body rather than from these callbacks, so 404, 204 and every other code still reach the checks unchanged. VERIFIED BY CONSTRUCTION, NOT BY TEST, and that is weaker than everything else on this branch. The behaviour is a dialog raised inside a real networked ConnectionRequest, which core-unittests cannot exercise; what was checked is the wiring named above, by reading it. Recorded plainly rather than left to look like the probes backing the other fixes. The change also exposed that aRelayThatIsNoLongerInstalledRefusesBeforeReadingItsToken had been passing for a TIMING reason. setRelay() starts a poll, and that poll's getToken() is entirely legitimate -- the relay is installed then -- but the test never waited for it, so whether the worker got there before the assertion was a race it happened to lose consistently until this change perturbed the timing. It now lets the poll finish and clears the flag, so it asserts only what it claims: that a REPLACED relay refuses before reading a token. The P1 guard was re-probed afterwards rather than assumed from a green suite -- disabling it still fails the test. Full module 6260/6260, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/continuity/RestStateRelay.java | 27 ++++++++++++++++++- .../continuity/LocalContinuityTest.java | 9 +++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index 67c4826894d..d396511480a 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -22,6 +22,7 @@ */ package com.codename1.continuity; +import com.codename1.io.rest.ErrorCodeHandler; import com.codename1.io.rest.RequestBuilder; import com.codename1.io.rest.Response; import com.codename1.io.rest.Rest; @@ -167,7 +168,31 @@ private RequestBuilder auth(RequestBuilder b) throws IOException { + "setRelay() replaced it. Refusing the request rather than sending one " + "account's state under another account's credentials."); } + // SILENT, because these are housekeeping requests the user never asked for. A request + // builder sets failSilently only when an error-code handler is registered, and without it + // ConnectionRequest puts a Retry/Cancel dialog in front of the user for both a failure + // response and a connection exception. The 404 below is the DOCUMENTED answer for a relay + // that holds nothing yet, so a correctly implemented endpoint showed every user an error + // dialog on first run -- for the ordinary case, before this class could read the code and + // call it an empty relay. + // + // The handler itself does nothing on purpose: getAsString() builds its Response from the + // request's own code and body rather than from these callbacks, so publish() and fetch() + // still see 404, 204 and everything else exactly as before. + RequestBuilder quiet = b.onErrorCodeString(SILENT); String token = getToken(); - return token == null || token.length() == 0 ? b : b.bearer(token); + return token == null || token.length() == 0 ? quiet : quiet.bearer(token); } + + /// Registered on every request purely to make it silent. See auth(). + /// + /// A constant rather than an anonymous class per request: it captures nothing, and an inner + /// class would hold its enclosing relay alive for no reason -- which SpotBugs reports as + /// SIC_INNER_SHOULD_BE_STATIC_ANON. + private static final ErrorCodeHandler SILENT = new ErrorCodeHandler() { + @Override + public void onError(Response errorData) { + // Deliberately nothing. The caller reads the response code and decides there. + } + }; } diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java index 16eaecba83d..9092cb2e07f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2390,6 +2390,15 @@ protected String getToken() { assertTrue(Continuity.isInstalledRelay(relay), "the relay was not installed, so the refusal below proves nothing"); + // setRelay() starts a poll, and THAT read is entitled to a token -- the relay is + // installed. Let it finish and forget it, because the property under test is what happens + // after the relay is replaced. Without this the test raced its own fixture: the worker + // sometimes reached getToken() before the assertion and sometimes did not, so it passed + // for a timing reason rather than a behavioural one. + pause(300L); + flushSerialCalls(); + tokenRead[0] = false; + // REPLACED, not cleared. clear() is a logout and deliberately keeps the relay installed: // the same endpoint usually serves the next account. What the framework can recognise is // a relay that is no longer the installed one, which is why switching accounts means From ff23d287d148b13a78f4875a321da33de4e5fab9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:55:54 +0300 Subject: [PATCH 066/140] Continuity: the unsupported-store simulation disables the store, not just its answer Ungating the facade left makeTheSyncedStoreUnsupported() overriding only isSyncedStoreSupported(), so the simulation kept a fully working store. Its own javadoc promises the opposite -- "an app that put a required setting in there and never checked isSupported() loses it here, silently, exactly as it would on Android" -- and that stopped being true the moment store calls stopped consulting the predicate. The hook now returns Android's answers for all four operations, which is the platform it exists to imitate. The other finding in this batch is REFUSED. It reads PREFIX + "Keys" as landing on the index, and it does not: PREFIX ends in '$', so that produces CN1$SyncedStore$Keys while the index is CN1$SyncedStoreKeys. No key can collide, because INDEX does not start with PREFIX. The reasoning is recorded at the constant rather than argued in a review reply, because the property worth protecting is that trailing '$' -- drop it, or move the index under the prefix, and put("Keys", ...) really would overwrite the index and be overwritten by it. The test took two attempts and the control caught both. A value-level check was impossible: Storage has no initialised runtime in that harness, and "put returned false" there is ambiguous between "this platform has no store" and "there was nowhere to write" -- the exact distinction under test. The first structural control then compared against LocalContinuityBridge itself, which declares those methods because it IS the implementation, so it proved nothing and failed saying so. The sibling hook is the control that discriminates: the same shape, an anonymous subclass overriding one predicate, which must not touch the store. Core module 6260/6260, javase module 326/326, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Proven by reverting the hook to overriding the predicate alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/LocalContinuityBridge.java | 11 +++ .../impl/javase/ContinuitySimulatorHooks.java | 28 ++++++ .../ContinuitySimulatorHooksTest.java | 99 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 17603a6e702..ac02576091d 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -56,6 +56,17 @@ public class LocalContinuityBridge implements ContinuityBridge { private static final String PREFIX = "CN1$SyncedStore$"; /// The list of keys, kept beside them because the store is addressed by name only. + /// + /// A SEPARATE namespace from the values, which is what makes it safe: PREFIX ends in `$` and + /// this does not, so no application key can ever be written to this name. Review read + /// `PREFIX + "Keys"` as landing here -- it produces `CN1$SyncedStore$Keys`, which is a value + /// like any other -- and the reasoning behind the answer is worth more than the answer: for a + /// collision to exist INDEX would have to start with PREFIX, and it does not. + /// + /// That is the property to preserve. Dropping the `$` from PREFIX, or renaming this to + /// something under it, would make `put("Keys", ...)` overwrite the index and then be + /// overwritten by it -- reported as a successful write whose value reads back as the key + /// list. private static final String INDEX = "CN1$SyncedStoreKeys"; // EDT-owned. Everything here runs on the Codename One event thread: the framework calls in diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java index 58b01be877e..18e79806a2c 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java @@ -147,6 +147,34 @@ public static void makeTheSyncedStoreUnsupported() { public boolean isSyncedStoreSupported() { return false; } + + // The OPERATIONS as well, not only the answer about them. The framework deliberately + // stopped gating store calls on isSyncedStoreSupported() -- on iOS the store is local + // and works whether or not this build is entitled to sync it -- so overriding the + // predicate alone left this simulation with a fully working store, and an application + // that ignores isSupported() kept its setting here while losing it on Android. That + // is the exact failure the menu item exists to reproduce. + // + // These are Android's answers, which is the platform being simulated: a write that + // does not happen, a read that finds nothing, and a removal with nothing to remove. + @Override + public boolean syncedStorePut(String key, String value) { + return false; + } + + @Override + public String syncedStoreGet(String key) { + return null; + } + + @Override + public void syncedStoreRemove(String key) { + } + + @Override + public String[] syncedStoreKeys() { + return new String[0]; + } }); } diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java new file mode 100644 index 00000000000..8881dbc94e4 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.continuity; + +import com.codename1.impl.continuity.LocalContinuityBridge; +import com.codename1.impl.javase.ContinuitySimulatorHooks; +import com.codename1.impl.javase.JavaSEPort; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The "synced store unsupported" simulation has to disable the STORE, not only its answer about + * itself. + * + *

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

+ */ +class ContinuitySimulatorHooksTest { + + @AfterEach + void restoreTheCapablePlatform() { + ContinuitySimulatorHooks.makeEverythingSupported(); + } + + @Test + void theUnsupportedStoreSimulationAlsoDisablesTheOperations() throws Exception { + // Asserted on what the installed bridge OVERRIDES rather than on values it stores. The + // store persists through Storage, which this harness has no initialised runtime for, so a + // value-level check could not run -- and worse, "put returned false" there would be + // ambiguous between "this platform has no store" and "there was nowhere to write", which + // is precisely the distinction the test exists to make. + // The control is the SIBLING hook, not the plain bridge. makeEverythingSupported() + // installs LocalContinuityBridge itself, which of course declares the store methods -- + // it is the implementation -- so comparing against it proved nothing and said so when it + // fired. makeContinuationUnsupported() installs the same SHAPE, an anonymous subclass + // overriding one predicate, and it must NOT touch the store. + ContinuitySimulatorHooks.makeContinuationUnsupported(); + Class otherHook = JavaSEPort.getSimulatedContinuity().getClass(); + + ContinuitySimulatorHooks.makeTheSyncedStoreUnsupported(); + LocalContinuityBridge unsupported = JavaSEPort.getSimulatedContinuity(); + + assertFalse(unsupported.isSyncedStoreSupported(), + "the hook did not make the store report itself unsupported"); + + Class off = unsupported.getClass(); + String[] operations = {"syncedStorePut", "syncedStoreGet", "syncedStoreRemove", + "syncedStoreKeys"}; + Class[][] signatures = { + {String.class, String.class}, {String.class}, {String.class}, {} + }; + for (int i = 0; i < operations.length; i++) { + assertTrue(declares(off, operations[i], signatures[i]), + operations[i] + " is inherited from the working store, so a platform with no " + + "synced store still keeps and returns values -- an application that " + + "ignores isSupported() passes here and loses its setting on Android"); + // The control: a hook about a DIFFERENT capability must not override them, or the + // check above would be satisfied by any anonymous bridge at all. + assertFalse(declares(otherHook, operations[i], signatures[i]), + "the continuation hook overrides " + operations[i] + " as well, so the " + + "assertion above is true of any hook and distinguishes nothing"); + } + } + + private static boolean declares(Class c, String name, Class[] args) { + try { + c.getDeclaredMethod(name, args); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } +} From 008b5e249b8e56e726a388f8cb8b3895b865f0a2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:28:23 +0300 Subject: [PATCH 067/140] Continuity: a listener that ends the session stops the dispatch it is inside Calling clear() or disable() from stateReceived() is the documented response to "this arrival belongs to another account", and dispatch carried on regardless. With automatic restore on it then restored and PERSISTED the signed-out account's state, moments after logout had deleted it; with a listener returning false it re-parked that state into a session that had just been emptied. A lifecycle counter is captured before any application code runs and checked between listeners and after the last one. Deliberately not relaySession, which every setRelay() moves as well: installing a relay from inside a listener is legitimate and must not abandon the dispatch that is running. This counts only the two calls that make everything after them meaningless. The second finding is a regression of mine. ContinuityListener documents rejection as a false case in as many words -- older than what is on screen, belongs to a different account -- and parking every false made that path hold this device's publications for ever unless the application also acknowledged. The hold stays, and the contract now says so. A boolean cannot separate "I reject this" from "I am asking the user", and guessing the other way loses work the user was about to accept -- while the rejection case is fixed by one documented call the application is already able to make. So the interface requires acknowledge() for a false you will not come back from, and the hold explains itself once per arrival instead of failing silently: a device that quietly stops syncing, with the cause nowhere near the symptom, is the worst version of this. The identity comparison for that once-per-arrival check takes the tree's //NOPMD marker; CompareObjectsWithEquals fails the build for any finding, and two states that considered themselves equal would still be two things worth telling the developer about. Core module 6262/6262, javase module 326/326, SpotBugs 0, forbidden PMD 0, copyright, control-character and cast gates clean. The lifecycle guard is proven by disabling BOTH of its checks. The first attempt at that probe counted the two sites with patterns where the shorter is a substring of the longer, so the assertion tripped, nothing was written, and the probe ran against the fixed code and reported success. Anchoring the match to line starts is what made it real. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 45 +++++++++++++++ .../continuity/ContinuityListener.java | 15 +++++ .../continuity/LocalContinuityTest.java | 57 +++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 36a17b5d7a7..889fec3d3f6 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -286,6 +286,7 @@ public static void disable() { if (!enabled) { return; } + lifecycle++; enabled = false; dirty = false; parked = null; @@ -1082,6 +1083,7 @@ public void run() { /// 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() { + lifecycle++; parked = null; dirty = false; // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft @@ -1393,6 +1395,16 @@ private static void clearContinuation() { /// read that succeeds. private static boolean fetchUnread; + /// Bumped whenever the application ends the current session -- clear() or disable(). + /// + /// Separate from relaySession, which every setRelay() moves too: installing a relay from + /// inside a listener is legitimate and must not abandon the dispatch that is running. This + /// counts only the two calls that make everything after them meaningless. + private static int lifecycle; + + /// The parked state the publication hold has already been explained for, so it is said once. + private static AppState heldFor; + /// The most recently completed arrival, as an in-process fact rather than a stored one. /// /// One slot is the right size: the only reader asks immediately after the listeners for the @@ -1452,6 +1464,21 @@ private static void startPublisher() { // Held rather than dropped. Whatever clears the slot -- the user accepting, the // application acknowledging, a logout, the state expiring -- calls back in here, and // an acknowledged state is safe to overwrite because the mark is already durable. + // IDENTITY: the same arrival, not an equal one. Explaining the hold once per + // arrival is the whole point, and two distinct states that considered themselves + // equal are still two things the developer needs telling about. + if (heldFor != awaitingDecision) { //NOPMD CompareObjectsWithEquals + // Once per arrival, not once per checkpoint. A hold that never ends is silent + // otherwise: this device simply stops publishing, and the cause -- a listener + // that returned false to REJECT a state and never acknowledged it -- is nowhere + // near the symptom. The framework cannot tell that from a prompt still waiting on + // the user, so it says what it is doing and names the way out. + heldFor = awaitingDecision; + Log.p("Continuity: holding checkpoints because an arrival is still undecided. " + + "If a listener returned false to reject it, call " + + "Continuity.acknowledge(state) -- otherwise nothing is published from " + + "this device again."); + } publishRequested = true; return; } @@ -1761,6 +1788,12 @@ private static void dispatch(AppState state) { park(state); return; } + // The lifecycle as it stands BEFORE any application code runs. A listener is entitled + // to call clear() or disable() -- discovering the arrival belongs to another account is + // exactly the decision this callback exists for -- and dispatch used to carry on + // regardless: it restored and PERSISTED the signed-out account's state after a logout had + // just deleted it, or re-parked it into a session that had been emptied. + int lifecycleAtDispatch = lifecycle; // A copy, because a listener that reacts by unregistering itself is ordinary and would // otherwise mutate the list being walked. List snapshot = new ArrayList(listeners); @@ -1772,6 +1805,12 @@ private static void dispatch(AppState state) { Log.e(t); continue; } + if (lifecycle != lifecycleAtDispatch) { + // clear() or disable() ran inside the callback. Everything after this point -- + // restoring, persisting, parking, marking -- would be acting for a session that + // no longer exists. + return; + } 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. @@ -1792,6 +1831,10 @@ private static void dispatch(AppState state) { return; } } + if (lifecycle != lifecycleAtDispatch) { + // Checked again after the LAST listener as well, not only between them. + return; + } if (autoRestore) { // The mark is written by restore(), through commit(), and ONLY when it committed // something. There was an unconditional rememberSeen() here, which quietly undid that: @@ -2433,6 +2476,8 @@ static void reset() { applyingRestore = false; storeCallbackInstalled = false; lastCompleted = null; + lifecycle = 0; + heldFor = null; } /// The store notification, as a constant rather than an anonymous class per callback. diff --git a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java index cf308c08447..50ecd0eb4d2 100644 --- a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java +++ b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java @@ -37,6 +37,21 @@ public interface ContinuityListener { /// that returns false has consumed the state: nothing is restored and no other listener is /// asked. /// + /// #### A false you will not come back from must acknowledge + /// + /// False keeps the state. It has to: the ordinary reason to return false is that you are + /// about to ask the user, and the arrival's only other copy may be on the relay -- so the + /// framework holds it, and holds this device's own checkpoints off the relay behind it, until + /// you say what happened. `Continuity.restore(AppState)` says accepted; + /// `Continuity.acknowledge(AppState)` says finished with. + /// + /// So a false that REJECTS -- the wrong account, older than the screen, anything you will + /// never restore -- must call `Continuity.acknowledge(AppState)`, or that hold never ends: + /// `Continuity.getRestorableState()` goes on offering the state you rejected, and this device + /// stops publishing to the relay for the rest of the process. The framework cannot tell a + /// rejection from a prompt that has not been answered yet, and guessing wrong in the other + /// direction loses work the user was about to accept. + /// /// Doing the work yourself and returning false is a supported pattern, and is how an app /// prompts before jumping: keep the state, return false, and call /// `Continuity.restore(AppState)` when the user accepts. 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 index 9092cb2e07f..bff1ea56147 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3144,6 +3144,63 @@ public boolean stateReceived(AppState state) { + "checkpoint waits behind it"); } + /** + * A listener that logs out mid-callback stops the dispatch it is inside. + * + *

Discovering that an arrival belongs to another account is exactly the decision this + * callback exists for, and calling clear() is the documented response. Dispatch carried on + * regardless: with automatic restore on it restored and PERSISTED the signed-out account's + * state, moments after logout had deleted it.

+ */ + @EdtTest + public void aListenerThatLogsOutStopsTheDispatchItIsInside() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + // "This is not the account that is signed in" -- the documented reason to log out. + Continuity.clear(); + return true; + } + }); + + Continuity.deliver(fromElsewhere("the previous account's work", 95L)); + flushSerialCalls(); + + assertNull(provider.restored, + "the signed-out account's state was restored after clear(), so its work is back " + + "on screen and back in storage moments after logout deleted it"); + assertNull(Continuity.getRestorableState(), + "logout left something restorable behind"); + } + + /** + * And disable() inside the callback stops it too. + * + *

disable() documents that arriving states are ignored from the moment it is called, which + * has to include the one being dispatched when it is called.

+ */ + @EdtTest + public void aListenerThatDisablesStopsTheDispatchItIsInside() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + Continuity.disable(); + return true; + } + }); + + Continuity.deliver(fromElsewhere("arrived as it was switched off", 96L)); + flushSerialCalls(); + + assertNull(provider.restored, + "a state was restored after disable(), which says arrivals are ignored from the " + + "moment it is called"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 10a7f26c6db55de5137fcea9a737c647eea716b8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:53:29 +0300 Subject: [PATCH 068/140] Continuity: logout deletes the delivery marks instead of overwriting them clear() emptied the maps and called rememberSeen(), which writes the emptied map -- one write, and a write storage refuses leaves the whole of the signed-out account's marks on disk for the next launch to reload. The checkpoint beside it has had overwrite-then-delete since the last round, precisely because deleteStorageFile() returns void and the ports discard the boolean they do get. The marks got the weaker treatment because they happened to be written through a helper, not because anyone decided they deserved it. The reason recorded here is not the one the finding led with. Its scenario -- the next account's state rejected at a lower sequence -- is thin: marks are keyed by device id, peer counters rise monotonically, and what would reset one, a reinstall, also produces a fresh id. What actually justifies the fix is that WHICH DEVICES an account synced with, and how far, is that account's data as much as its routes and payload are, and logout says it forgets those. Full module 6263/6263, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Proven by removing the delete, which fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 20 +++++++++++-- .../continuity/LocalContinuityTest.java | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 889fec3d3f6..535ffa730e1 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1111,10 +1111,24 @@ public static void clear() { // dealt with, and keeping it would let a state re-delivered to the next account be // treated as already handled. lastCompleted = null; - // The durable copy as well. 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. + // The durable copy as well, and DELETED rather than merely overwritten. Leaving it + // behind meant the marks of the account that just signed out kept suppressing the NEXT + // account's deliveries -- a state silently never arriving, which is harder to notice + // than one arriving twice. + // + // rememberSeen() alone was not enough: it writes the emptied map, and a write storage + // refuses leaves the whole of the previous account's marks on disk for the next launch to + // reload. Which devices an account synced with, and how far, is that account's data as + // much as its routes are -- so this gets the same treatment as the checkpoint below, + // rather than the weaker one it had because it happened to be written through a helper. rememberSeen(); + try { + if (Display.isInitialized() && Storage.getInstance().exists(PREF_SEEN)) { + Storage.getInstance().deleteStorageFile(PREF_SEEN); + } + } catch (Throwable t) { + Log.e(t); + } clearContinuation(); // The route history is the previous account's work as surely as the stored checkpoint is. // Leaving it kept two promises broken: back() reopened the signed-out account's forms, 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 index bff1ea56147..8096770159f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3201,6 +3201,36 @@ public boolean stateReceived(AppState state) { + "moment it is called"); } + /** + * Logout removes the delivery marks even when the write that empties them is refused. + * + *

rememberSeen() writes the emptied map, and a write storage refuses leaves the whole of + * the signed-out account's marks on disk for the next launch to reload. Which devices an + * account synced with, and how far, is that account's data as much as its routes are.

+ */ + @EdtTest + public void logoutRemovesTheDeliveryMarksEvenWhenTheWriteIsRefused() { + Continuity.setStateProvider(new RecordingProvider()); + // An acknowledged arrival, so there is a durable mark to lose. + Continuity.acknowledge(fromElsewhere("dealt with", 9L)); + flushSerialCalls(); + assertFalse(Continuity.readSeenForTest().isEmpty(), + "no mark was written, so logout has nothing to remove and this proves nothing"); + + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEEN)); + try { + Continuity.clear(); + } finally { + Storage.setStorageInstance(original); + } + + assertTrue(Continuity.readSeenForTest().isEmpty(), + "the signed-out account's delivery marks are still on disk, so the next launch " + + "reloads which devices it synced with and how far: " + + Continuity.readSeenForTest()); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 907c6f266ceeb273da9b54feb77af7b63ae96a6d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:26:14 +0300 Subject: [PATCH 069/140] Continuity: refuse redirects on authenticated relay requests, and stop local limits escaping into remote decoding A redirect is followed with the same headers, and RequestBuilder requests follow them by default. So a 307 hands the bearer token AND the state to whatever host the endpoint names -- an http:// one included, silently undoing the HTTPS the relay's constructor insists on -- while a 302 or 303 turns the POST into a GET and the 2xx that follows makes publish() report a write that never happened. ConnectionRequest has had setFollowRedirects per request all along; RequestBuilder simply never passed it through, so there was no way to say this from the Rest API. It has one now, and the relay uses it on the single funnel both requests go through. Refusing is the right answer for a relay: one that has moved should be configured with its new URL, which is the application's decision and not a header's. fromJson no longer applies this device's WRITE validation to a document it is READING. A route longer than the local stored-string limit threw IllegalArgumentException out of a method that documents only malformed JSON, the relay read that as a failed fetch, and -- because the document never changes -- every retry failed identically and the fetchUnread hold stopped this device publishing for good. The payload beside it already had setPayloadUnchecked for exactly this reason; routes, title and device were missed. Carried rather than refused, because the limit is about writing: persist() reports its own failure to the one caller that must not act on it. And a restore now cancels the checkpoint a navigation had already SCHEDULED. That is the fourth outbound path for the same staleness -- the queued publish, the advertised activity, the stored checkpoint, and now the capture routeStackChanged had queued, which captured the arrival under this device's id and published the echo straight back to the device it came from. LocalCallTest stops comparing timer COUNTS. Earlier in this branch that assertion failed with "expected 4 but was 2" -- two unrelated JVM timers had expired -- and I weakened it to "not more than before" instead of fixing what it measured. The weakening made it pass for the wrong reason: a count sets this call's leaked safety timer against every other timer in the JVM, so two unrelated ones expiring hides one that leaked. It now tracks thread IDENTITY, so unrelated timers may start or stop freely and are never counted. The counting helper went with it, because an uncalled private method fails the SpotBugs gate. That last change is NOT probe-verified. The masking race needs unrelated timers to expire on cue and the harness cannot stage it; an attempt to inject a leak failed on an unrelated initialisation error and proved nothing, so it is not being counted. The identity form is stronger by construction rather than by demonstration. The scheduled-capture test took three attempts and the probe rejected the first two: calling routeStackChanged() directly queued the flush AHEAD of the dispatch so the scenario never arose, and asserting on isCheckpointPending() passed either way because checkpoint() clears that flag itself. Only asserting on what reached the relay distinguishes them. Full module 6265/6265, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 23 +++++ .../com/codename1/continuity/Continuity.java | 9 ++ .../codename1/continuity/RestStateRelay.java | 11 ++- .../com/codename1/continuity/StateCodec.java | 10 +- .../com/codename1/io/rest/RequestBuilder.java | 30 ++++++ .../com/codename1/call/LocalCallTest.java | 64 +++++++++---- .../continuity/LocalContinuityTest.java | 94 +++++++++++++++++++ 7 files changed, 218 insertions(+), 23 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java index d335d01a668..049f729a0d7 100644 --- a/CodenameOne/src/com/codename1/continuity/AppState.java +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -150,6 +150,29 @@ void setPayloadUnchecked(Map p) { payload = deepCopy(p); } + /// The other three fields a REMOTE document supplies, set without the local size validation. + /// + /// Same reason as the payload beside them, and they were simply missed: a document from + /// another device goes through the validating setters, so one route longer than this device's + /// stored-string limit threw IllegalArgumentException out of StateCodec.fromJson -- which + /// fromJson does not document and the relay reads as a FAILED fetch. The document never + /// changes, so every retry fails identically and this device stops publishing for good. + /// + /// Carried rather than refused, because the limit is about writing: a state that cannot be + /// stored here can still be restored here, and persist() already reports its own failure to + /// the one caller that must not act on it. + void setRoutesUnchecked(List r) { + routes = r == null ? new ArrayList() : new ArrayList(r); + } + + void setDeviceIdUnchecked(String id) { + deviceId = id == null ? "" : id; + } + + void setTitleUnchecked(String t) { + title = t; + } + /// Copies a payload all the way down, not just its outer map. /// /// A shallow copy left the snapshot sharing the application's own lists and maps. That is a diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 535ffa730e1..5d51fca3f6d 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1266,6 +1266,15 @@ private static boolean commit(AppState state, boolean applied, boolean failed) { // prevent. Nothing goes out until the user does something new. pendingPublish = null; publishRequested = false; + // And the SCHEDULED capture, which is a fourth way the same stale screen gets out. + // routeStackChanged() sets `dirty` and queues a flush; that flush asks only whether a + // checkpoint is pending, so it ran after the restore, captured the state that had + // just ARRIVED under this device's identity, and published the very echo this block + // exists to suppress -- which the origin then accepts and restores on its next poll. + // + // Clearing it is right for the same reason the slot is cleared: whatever the user did + // before the restore describes a screen the restore has replaced. + dirty = false; // The ADVERTISED activity is stale in exactly the same way, and dropping only the // queued publish left half the job done. The platform activity stays current until // something replaces or withdraws it, and applyingRestore suppresses the checkpoint diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index d396511480a..9bfe8bda1d7 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -179,7 +179,16 @@ private RequestBuilder auth(RequestBuilder b) throws IOException { // The handler itself does nothing on purpose: getAsString() builds its Response from the // request's own code and body rather than from these callbacks, so publish() and fetch() // still see 404, 204 and everything else exactly as before. - RequestBuilder quiet = b.onErrorCodeString(SILENT); + // NO REDIRECTS, because this request carries a bearer token. A redirect is followed + // with the same headers, so a 307 would hand the token and the state to whatever host + // the response names -- an `http://` one included, silently undoing the HTTPS the + // constructor insists on. A 302 or 303 is not safer, only different: it turns the POST + // into a GET, and the 2xx that follows makes publish() report a write that never + // happened. + // + // A relay that has moved should say so by being configured with its new URL, which is + // the application's decision to make and not a header's. + RequestBuilder quiet = b.followRedirects(false).onErrorCodeString(SILENT); String token = getToken(); return token == null || token.length() == 0 ? quiet : quiet.bearer(token); } diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 7691bcb6074..1788a849bf9 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -141,7 +141,11 @@ public static AppState fromMap(Map m) { paths.add((String) path); } } - state.setRoutes(paths); + // Unchecked, exactly as the payload below is: this document came from another + // device, and one route past this device's stored-string limit used to throw out of + // fromJson -- which the relay reads as a failed fetch, and the document never + // changes, so this device stopped publishing for good. + state.setRoutesUnchecked(paths); } // Whether the values are tagged is the DOCUMENT's answer, not a guess made per string. // See KEY_ENCODING: without it, "i:5" is just a string and stays one. @@ -164,11 +168,11 @@ public static AppState fromMap(Map m) { } Object device = m.get(KEY_DEVICE); if (device instanceof String) { - state.setDeviceId((String) device); + state.setDeviceIdUnchecked((String) device); } Object title = m.get(KEY_TITLE); if (title instanceof String) { - state.setTitle((String) title); + state.setTitleUnchecked((String) title); } state.setSequence(asLong(m.get(KEY_SEQUENCE))); state.setTimestamp(asLong(m.get(KEY_TIMESTAMP))); diff --git a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java index 9ef7c721c69..4534e40905f 100644 --- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java +++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java @@ -79,6 +79,9 @@ public class RequestBuilder { private ErrorCodeHandler byteArrayErrorCallback; private ErrorCodeHandler jsonErrorCallback; private ErrorCodeHandler stringErrorCallback; + + /// Whether a redirect may be followed. True to match ConnectionRequest's own default. + private boolean followRedirects = true; private ErrorCodeHandler propertyErrorCallback; private Class errorHandlerPropertyType; //private ActionListener errorCallback; @@ -420,6 +423,30 @@ public RequestBuilder onErrorCode(ErrorCodeHandler err, /// #### Returns /// /// RequestBuilder instance + /// Whether this request may follow a redirect. Requests follow them by default. + /// + /// Turn it off for a request that carries CREDENTIALS. A redirect is followed with the same + /// headers, so a 307 hands the Authorization header -- and the body -- to whatever host the + /// response names, including an `http://` one, which silently undoes a caller that was + /// careful to use HTTPS. A 302 or 303 is not safer, only different: it turns a POST into a + /// GET and the final 2xx then reports success for a write that never happened. + /// + /// `ConnectionRequest` has always had this per request; this passes it through, which is all + /// that was missing. + /// + /// #### Parameters + /// + /// - `follow`: false to refuse redirects + /// + /// #### Returns + /// + /// RequestBuilder instance + public RequestBuilder followRedirects(boolean follow) { + checkFetched(); + followRedirects = follow; + return this; + } + public RequestBuilder onErrorCodeString(ErrorCodeHandler err) { checkFetched(); stringErrorCallback = err; @@ -1044,6 +1071,9 @@ private Connection createRequest(boolean parseJson) { req.setContentType(contentType); } req.setFailSilently(hasErrorCodeHandler()); + if (!followRedirects) { + req.setFollowRedirects(false); + } if (cache != null) { req.setCacheMode(cache); } diff --git a/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java index 1a8d47af728..2f7a2a9dce2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java @@ -687,7 +687,7 @@ public void deferringTwiceLeavesNoTimerThreadBehind() { // a desktop JVM alive long after the call is over. The action being // answered exactly once hides this completely at the API level; the // only thing that shows it is the thread. - int before = liveTimerThreads(); + java.util.Set before = timerThreads(); final List seen = new ArrayList(); Calls.addActionListener(new CallActionAdapter() { public void endRequested(String callId, CallAction action) { @@ -703,34 +703,60 @@ public void endRequested(String callId, CallAction action) { seen.get(0).fulfill(); long limit = System.currentTimeMillis() + 2000; - while (liveTimerThreads() > before + while (!survivingNewTimers(before).isEmpty() && System.currentTimeMillis() < limit) { sleep(); } - // NOT MORE than before, rather than exactly the same. What this test is about is a - // safety timer that outlives the answer, so growth is the failure; a DROP is not. The - // count is JVM-wide -- liveTimerThreads() matches any thread named "Timer-" -- so timers - // started by earlier tests in this run can expire inside the window above, and the loop - // exits immediately when that happens because the count is no longer greater than the - // baseline. Equality then failed with "expected 4 but was 2": two unrelated timers had - // finished, which is exactly what should happen and has nothing to do with call - // deferral. - assertTrue(liveTimerThreads() <= before, - "answering must leave no safety timer running"); - } - - /// Live java.util.Timer threads, which is where a leaked safety net shows. - private static int liveTimerThreads() { - int n = 0; + // IDENTITY, not a count. These threads are JVM-wide -- anything named "Timer-" -- so a + // count compares this call's safety timer against every other timer in the run, and + // arithmetic hides the thing under test: two unrelated timers expiring inside the window + // offsets one that leaked, and the assertion passes. + // + // That is not hypothetical. This started as an equality check, failed with "expected 4 + // but was 2" when two unrelated timers finished -- which is correct behaviour and nothing + // to do with call deferral -- and was weakened to "not more than before" to quiet it. The + // weakening made it pass for the wrong reason instead of fixing what it measured. + // + // Tracking which threads existed BEFORE answers the actual question: did answering leave + // a timer of its own running. Unrelated timers may start or stop freely and are never + // counted, because they are compared by identity rather than by number. + java.util.Set leaked = survivingNewTimers(before); + assertTrue(leaked.isEmpty(), + "answering left a safety timer running: " + names(leaked)); + } + + /// The live java.util.Timer threads, BY IDENTITY, which is where a leaked safety net shows. + private static java.util.Set timerThreads() { + java.util.Set out = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap()); Thread[] all = new Thread[Thread.activeCount() * 2 + 16]; int found = Thread.enumerate(all); for (int i = 0; i < found; i++) { Thread t = all[i]; if (t != null && t.isAlive() && t.getName().startsWith("Timer-")) { - n++; + out.add(t); + } + } + return out; + } + + /// Timer threads that are alive now and were not alive before -- the only ones this action + /// can be held responsible for. + private static java.util.Set survivingNewTimers(java.util.Set before) { + java.util.Set now = timerThreads(); + now.removeAll(before); + return now; + } + + private static String names(java.util.Set threads) { + StringBuilder sb = new StringBuilder(); + for (Thread t : threads) { + if (sb.length() > 0) { + sb.append(", "); } + sb.append(t.getName()); } - return n; + return sb.toString(); } @Test diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java index 8096770159f..fafd5677d16 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -26,6 +26,8 @@ import com.codename1.continuity.sync.SyncedStoreListener; import com.codename1.impl.continuity.LocalContinuityBridge; import com.codename1.io.Storage; +import com.codename1.io.rest.RequestBuilder; +import com.codename1.io.rest.Rest; import com.codename1.junit.EdtTest; import com.codename1.router.Navigation; import com.codename1.router.RouteDispatcher; @@ -3231,6 +3233,98 @@ public void logoutRemovesTheDeliveryMarksEvenWhenTheWriteIsRefused() { + Continuity.readSeenForTest()); } + /** + * The relay's requests refuse redirects, because they carry a bearer token. + * + *

A redirect is followed with the same headers, so a 307 hands the token and the state to + * whatever host the response names -- including an {@code http://} one, which silently undoes + * the HTTPS the constructor insists on. A 302 turns the POST into a GET and the 2xx that + * follows reports a write that never happened.

+ * + *

Asked of the builder the relay actually produces, so it fails if the call is dropped + * from {@code auth()}, and paired with a control: a plain request still follows redirects, + * which is CodenameOne's default and not something this may change for everyone.

+ */ + @EdtTest + public void theRelayRefusesRedirectsOnItsAuthenticatedRequests() throws Exception { + RestStateRelay relay = new RestStateRelay("https://example.invalid/continuity"); + Continuity.setRelay(relay); + + java.lang.reflect.Method auth = RestStateRelay.class.getDeclaredMethod( + "auth", RequestBuilder.class); + auth.setAccessible(true); + RequestBuilder built = (RequestBuilder) auth.invoke( + relay, Rest.post("https://example.invalid/continuity")); + + assertFalse(followsRedirects(built), + "the relay's requests follow redirects, so a 307 forwards the bearer token and " + + "the state to whatever host the endpoint names"); + assertTrue(followsRedirects(Rest.post("https://example.invalid/continuity")), + "an ordinary request stopped following redirects, which is a change to every " + + "caller rather than to this one"); + } + + private static boolean followsRedirects(RequestBuilder b) throws Exception { + java.lang.reflect.Field f = RequestBuilder.class.getDeclaredField("followRedirects"); + f.setAccessible(true); + return ((Boolean) f.get(b)).booleanValue(); + } + + /** + * A restore cancels the checkpoint a navigation had already scheduled. + * + *

routeStackChanged() sets the pending flag and queues a flush, and that flush asks only + * whether a checkpoint is pending. So it ran after the restore, captured the state that had + * just ARRIVED under this device's identity, and published the echo the restore path exists + * to suppress -- which the origin then accepts and restores on its next poll.

+ */ + @EdtTest + public void aRestoreCancelsTheCheckpointANavigationHadScheduled() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("screen", "before the arrival"); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() { + return null; + } + }); + pause(200L); + flushSerialCalls(); + published.clear(); + + // The ORDER is the whole test, and getting it wrong is why the first version passed + // against the unfixed code. deliver() queues admission, admission queues the dispatch a + // turn later, and the navigation has to land BETWEEN them -- so its flush is queued + // behind the dispatch and runs after the restore has committed. Calling + // routeStackChanged() directly put the flush FIRST, where checkpoint() cleared the + // pending flag itself and the fix could not be observed at all. + Continuity.deliver(fromElsewhere("what the other device was doing", 97L)); + Display.getInstance().callSerially(new Runnable() { + public void run() { + Continuity.routeStackChanged(); + assertTrue(Continuity.isCheckpointPending(), + "the navigation scheduled nothing, so there is no capture to cancel"); + } + }); + flushSerialCalls(); + assertNotNull(provider.restored, "the arrival was not applied, so this test is vacuous"); + + // Asserted on what reached the RELAY, not on the pending flag. checkpoint() clears that + // flag itself, so it reads false whether the capture was cancelled or performed -- a + // second version of this test asserted on it and passed against the unfixed code. + pause(300L); + flushSerialCalls(); + assertTrue(published.isEmpty(), + "the checkpoint scheduled before the restore still ran, so the arrival went back " + + "out under this device's id -- the echo the origin then accepts and " + + "restores on its next poll: published=" + published.size()); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From e8c2f7284ff75ed8327e5f7effd3beaaf107ca36 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:58:22 +0300 Subject: [PATCH 070/140] Continuity: scope the redirect guarantee honestly, and undo two defects the last fixes introduced The redirect refusal added last round is INERT on iOS and the native macOS port that inherits its networking. ConnectionRequest.setFollowRedirects says so in its own javadoc -- "doesn't work on iOS which always implicitly redirects" -- and the port answers cn1.nativeRedirects with true because NSURLSession takes the redirect inside the native stack. So the flag was a false assurance on the platform where continuity matters most, and the comment claiming these requests refuse redirects was untrue there. There is no workaround from here, and pretending otherwise would be worse than the gap: the redirect happens before this code sees a response, so it cannot be prevented OR detected afterwards. What the class can do is stop the guarantee being silent. It says once per process that this platform will follow redirects whatever it asks, so an endpoint that redirects is something a developer knows to fix rather than a token that quietly went somewhere else. The simulated store's keys are now encoded. Moving it off Preferences last round fixed a read-back that confirmed writes from its own cache and introduced this: Storage folds / \ % ? * : and = to _ in a file name, so "a/b" and "a_b" addressed one value -- both writes reported success, the index listed both, either read returned whichever was written last, and removing one deleted the other. Escaping those characters reversibly makes a collision impossible rather than unlikely. And clearStack() notifies continuity again. It was made silent so that Continuity.clear() could empty the stack without checkpointing it back over the storage being deleted -- which made it silent for every other caller too, so an application that forgot its back history and did not then navigate left the previous routes in the checkpoint, and a process death restored exactly what it had just cleared. The suppression now lives at the logout end, where the reason for it is. All three logout tests still pass, which is what proves the move did not weaken the case it was built for. Full module 6267/6267, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. The two behavioural fixes are each proven by reverting them alone; the redirect change is a documented scoping of a guarantee that cannot be enforced, not a behavioural claim. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 16 ++++++ .../codename1/continuity/RestStateRelay.java | 40 +++++++++++++++ .../continuity/LocalContinuityBridge.java | 37 ++++++++++++-- .../src/com/codename1/router/Navigation.java | 13 +++-- .../continuity/LocalContinuityTest.java | 49 +++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 5d51fca3f6d..0f06e6a77b1 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -515,6 +515,11 @@ public static void routeStackChanged() { return; } dirty = true; + if (clearingStack) { + // The logout emptying the stack, not the user going anywhere. Checkpointing it would + // write the emptied stack over the state clear() is in the middle of deleting. + return; + } if (!Display.isInitialized() || flushScheduled) { return; } @@ -1139,9 +1144,16 @@ public static void clear() { // either way round, a checkpoint here would write the emptied stack over what is being // removed. try { + // Suppressed HERE, not in Navigation. clearStack() notifies for every other caller, + // because forgetting the back history really is a change worth checkpointing -- it is + // only this one that must not, since a checkpoint would write the emptied stack + // straight back over the storage being deleted two lines down. + clearingStack = true; Navigation.clearStack(); } catch (Throwable t) { Log.e(t); + } finally { + clearingStack = false; } try { if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) { @@ -1425,6 +1437,9 @@ private static void clearContinuation() { /// counts only the two calls that make everything after them meaningless. private static int lifecycle; + /// True while clear() is emptying the route stack, so its notification is ignored. + private static boolean clearingStack; + /// The parked state the publication hold has already been explained for, so it is said once. private static AppState heldFor; @@ -2501,6 +2516,7 @@ static void reset() { lastCompleted = null; lifecycle = 0; heldFor = null; + clearingStack = false; } /// The store notification, as a constant rather than an anonymous class per callback. diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index 9bfe8bda1d7..4d9881f5dac 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -22,6 +22,8 @@ */ package com.codename1.continuity; +import com.codename1.io.Log; +import com.codename1.ui.Display; import com.codename1.io.rest.ErrorCodeHandler; import com.codename1.io.rest.RequestBuilder; import com.codename1.io.rest.Response; @@ -188,11 +190,49 @@ private RequestBuilder auth(RequestBuilder b) throws IOException { // // A relay that has moved should say so by being configured with its new URL, which is // the application's decision to make and not a header's. + // + // WHERE THE PLATFORM ALLOWS IT. On iOS and the native macOS port that inherits its + // networking, NSURLSession follows redirects inside the native stack before the framework + // sees the response -- ConnectionRequest.setFollowRedirects says so in as many words, and + // the port answers "cn1.nativeRedirects" with true. This flag is not a promise there, and + // saying nothing about that would have been a false assurance in the one place it matters + // most. Reported once per process, because an endpoint that redirects has to be fixed at + // the endpoint: nothing in this class can stop it. + warnIfRedirectsCannotBeRefused(); RequestBuilder quiet = b.followRedirects(false).onErrorCodeString(SILENT); String token = getToken(); return token == null || token.length() == 0 ? quiet : quiet.bearer(token); } + /// Says so, once, when the platform will follow redirects whatever this class asks. + /// + /// Not a workaround -- there is none from here. The redirect is taken inside the native + /// networking stack, so this code never sees the response that ordered it and cannot inspect + /// where the request actually went. What it can do is stop the guarantee from being silent, + /// so an endpoint that redirects is a thing somebody knows to fix rather than a token that + /// quietly went somewhere else. + private static void warnIfRedirectsCannotBeRefused() { + if (redirectWarningSaid) { + return; + } + redirectWarningSaid = true; + try { + if (!"true".equals(Display.getInstance().getProperty("cn1.nativeRedirects", "false"))) { + return; + } + } catch (Throwable t) { + Log.e(t); + return; + } + Log.p("Continuity: this platform follows HTTP redirects inside its native networking, so " + + "the relay cannot refuse them. A redirect from the relay endpoint would carry " + + "the bearer token to wherever it points. Make sure the endpoint answers " + + "directly rather than redirecting."); + } + + /// So the warning above is said once rather than on every request. + private static boolean redirectWarningSaid; + /// Registered on every request purely to make it silent. See auth(). /// /// A constant rather than an anonymous class per request: it captures nothing, and an inner diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index ac02576091d..5b962ebb4ec 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -195,7 +195,7 @@ public boolean syncedStorePut(String key, String value) { // Same correction the sequence counter and the delivery marks already needed. The // simulation has to answer the question the device answers -- is the value there now -- // and only a checked write can. - if (!write(PREFIX + key, value)) { + if (!write(storageName(key), value)) { return false; } List keys = indexKeys(); @@ -211,6 +211,37 @@ public boolean syncedStorePut(String key, String value) { return true; } + /// The storage name for an application key, encoded so that distinct keys cannot collide. + /// + /// Storage normalizes `/`, `\\`, `%`, `?`, `*`, `:` and `=` to `_` in a file name, so + /// "a/b" and "a_b" addressed the SAME value: both writes reported success, the index listed + /// both keys, and either read returned whichever was written last while removing one deleted + /// the other. That arrived with the move off Preferences -- which has no such rule -- so it + /// is a defect this class introduced while fixing a different one, not an old one. + /// + /// Every character Storage would rewrite is escaped as `$` and two hex digits, and `$` itself + /// with it, which makes the mapping reversible and therefore collision-free: two different + /// keys cannot produce one name. The keys themselves are unrestricted, exactly as the + /// platform store leaves them. + private static String storageName(String key) { + StringBuilder sb = new StringBuilder(PREFIX); + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if (c == '/' || c == '\\' || c == '%' || c == '?' || c == '*' || c == ':' + || c == '=' || c == '$') { + sb.append('$'); + String hex = Integer.toHexString(c).toUpperCase(); + if (hex.length() < 2) { + sb.append('0'); + } + sb.append(hex); + } else { + sb.append(c); + } + } + return sb.toString(); + } + /// Writes one value, reporting whether it actually reached storage. private boolean write(String name, String value) { try { @@ -239,13 +270,13 @@ private String read(String name) { @Override public String syncedStoreGet(String key) { - return read(PREFIX + key); + return read(storageName(key)); } @Override public void syncedStoreRemove(String key) { try { - Storage.getInstance().deleteStorageFile(PREFIX + key); + Storage.getInstance().deleteStorageFile(storageName(key)); } catch (Throwable t) { Log.e(t); } diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index ff90c09e989..8f2d915a9de 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -145,11 +145,18 @@ public static List getStack() { /// their routes. /// /// The forms themselves are not touched: whatever is on screen stays there, and the caller - /// navigates wherever it means to go next. Nor does this notify continuity. Forgetting where - /// the user has been is not the user going somewhere, and a checkpoint here would write the - /// emptied stack straight back over the one just deleted. + /// navigates wherever it means to go next. + /// + /// Continuity IS notified, because for every caller except a logout this is a real change to + /// where the user has been. It used to stay silent so that `Continuity.clear()` could call it + /// without checkpointing the emptied stack back over what it was deleting -- but that made + /// every other caller silent too: an application forgetting its back history and then not + /// navigating left the previous routes in the stored checkpoint, so a process death restored + /// exactly what it had just cleared. The logout path suppresses this at its own end, where + /// the reason to suppress it lives. public static void clearStack() { stack.clear(); + stackChanged(); } /// Pop entries until `entry` is on top, then show its form via 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 index fafd5677d16..d79eaefe316 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3325,6 +3325,55 @@ public void run() { + "restores on its next poll: published=" + published.size()); } + /** + * Keys that Storage would fold together stay distinct in the simulated store. + * + *

Storage normalizes {@code /}, {@code %}, {@code ?}, {@code *}, {@code :} and {@code =} + * to {@code _} in a file name, so "a/b" and "a_b" addressed the same value: both writes + * reported success, the index listed both keys, and either read returned whichever was + * written last while removing one deleted the other. That arrived with the move off + * Preferences, which has no such rule -- a defect introduced while fixing a different one.

+ */ + @EdtTest + public void keysThatStorageWouldFoldTogetherStayDistinct() { + assertTrue(SyncedStore.put("a/b", "slash"), "the first key was refused"); + assertTrue(SyncedStore.put("a_b", "underscore"), "the second key was refused"); + + assertEquals("slash", SyncedStore.get("a/b", "missing"), + "\"a/b\" reads back the value written under \"a_b\", so the two share a " + + "storage name"); + assertEquals("underscore", SyncedStore.get("a_b", "missing"), + "\"a_b\" lost its own value"); + + // And removing one must not take the other with it. + SyncedStore.remove("a/b"); + assertEquals("missing", SyncedStore.get("a/b", "missing"), "the removal did not happen"); + assertEquals("underscore", SyncedStore.get("a_b", "missing"), + "removing \"a/b\" deleted the value stored under \"a_b\""); + } + + /** + * Forgetting the back history is a change worth checkpointing. + * + *

clearStack() stayed silent so that logout could call it without checkpointing the + * emptied stack back over the storage it was deleting -- which made it silent for every other + * caller too. An application that forgot its history and did not then navigate left the + * previous routes in the stored checkpoint, so a process death restored exactly what it had + * just cleared.

+ */ + @EdtTest + public void forgettingTheBackHistoryIsCheckpointed() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.checkpoint(); + assertFalse(Continuity.isCheckpointPending(), "the fixture starts with nothing owed"); + + Navigation.clearStack(); + + assertTrue(Continuity.isCheckpointPending(), + "clearing the back history scheduled no checkpoint, so the previous routes stay " + + "in storage and a process death restores what was just cleared"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 913275f7c3714f0f732d3c47527cd380da71f5c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:26:51 +0300 Subject: [PATCH 071/140] Continuity: a continuation arriving before enable() is declined, not swallowed The iOS port holds a DECLINED activity and offers it again the next time a callback is installed, and its comment describes the exact case: a SyncedStore listener installs the same callback without enabling continuity -- a key/value store is not consent to restore a route stack -- and on a cold launch that happens before the application's init() calls enable(). The core callback never declined. It claimed anything of its own activity type whether or not the framework was on, reasoning that claiming keeps it from a handler that would do nothing with it. So the port's retention was written for an answer that never came, and admit() then dropped the arrival because the framework was disabled: an application doing startup work or asking consent before enabling continuity lost its cold-launch Handoff for good, purely because it had initialised the synced store first. Two pieces of this feature disagreeing, not one of them being wrong on its own. Declining is the better half: nothing else answers to this app's own activity type, so there is no handler to lose it to, and the port already knows how to hold it until enable() asks again. The decline reads `enabled` from the platform's thread, which the rest of that method deliberately avoids -- the type check is a pure function of the package name for that reason. It is safe in the one direction that matters: a decline is RECOVERABLE, because the activity is retained and re-offered, so losing the race can only delay a delivery and never lose one. The opposite asymmetry would have needed a different mechanism. The test asks the callback directly through a seam rather than through a platform, and asserts both halves: declined while disabled, and claimed once enabled -- without the second, the fix would be a feature that never works. Full module 6268/6268, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character, cast and native-signature gates clean. Proven by claiming again while disabled, which fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 30 +++++++++++++++--- .../continuity/LocalContinuityTest.java | 31 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 0f06e6a77b1..d9aa1e7cb52 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -2104,6 +2104,12 @@ private static boolean supersedesParked(AppState state) { && waiting.getSequence() <= state.getSequence(); } + /// Test seam: the inbound callback a port is given, so the decline-while-disabled answer can + /// be asked directly rather than through a platform. + static ContinuityCallback callbackForTest() { + return new Callback(); + } + /// Test seam: parks a state, as a cold-launch arrival with no form yet does. static void parkForTest(AppState state) { parked = state; @@ -2538,13 +2544,29 @@ public boolean continuationReceived(String activityType, Map use // Called on the platform's thread, and answered from the activity type ALONE. The // port needs a synchronous yes or no -- its answer decides whether the activity falls // through to another handler -- and the type is a pure function of the package name, - // so nothing here has to read framework state from a foreign thread. `enabled` is - // asked on the event thread, in admit(): an activity of this app's own type is - // ours to claim whether or not the framework happens to be on, and claiming it is what - // keeps it from being offered to a handler that would do nothing with it. + // so the FIRST question here reads no framework state from a foreign thread. if (activityType == null || !activityType.equals(getActivityType())) { return false; } + if (!enabled) { + // DECLINED while the framework is off, which is the answer the iOS port is built + // for: it holds a declined activity and offers it again the next time a callback + // is installed, and enable() installs one. Claiming it instead threw it away -- + // admit() drops an arrival when the framework is disabled, so an application that + // registers a SyncedStore listener before enabling continuity, which installs + // this same callback, lost a cold-launch Handoff for good. + // + // The two sides disagreed rather than one being wrong: this claimed everything of + // its own type so no other handler could take it, while the port's retention was + // written for a decline that never came. Declining is strictly better, because + // nothing else answers to this app's own activity type anyway. + // + // `enabled` is read here from the platform's thread, which the rest of this + // method deliberately avoids. It is safe in the one direction that matters: a + // decline is RECOVERABLE -- the activity is retained and re-offered -- so losing + // the race can only delay the delivery, never lose it. + return false; + } AppState state = StateCodec.fromMap(userInfo); if (state == null) { return false; 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 index d79eaefe316..977c4ad6176 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -22,6 +22,7 @@ */ package com.codename1.continuity; +import com.codename1.continuity.spi.ContinuityCallback; import com.codename1.continuity.sync.SyncedStore; import com.codename1.continuity.sync.SyncedStoreListener; import com.codename1.impl.continuity.LocalContinuityBridge; @@ -3374,6 +3375,36 @@ public void forgettingTheBackHistoryIsCheckpointed() { + "in storage and a process death restores what was just cleared"); } + /** + * A continuation arriving while continuity is off is DECLINED, so the port can hold it. + * + *

SyncedStore.addChangeListener() installs the same callback without enabling continuity -- + * a key/value store is not consent to restore a route stack -- and on a cold launch that + * happens before the application's init() calls enable(). The iOS port holds a declined + * activity and offers it again when the next callback is installed; claiming it instead threw + * it away, because admit() drops an arrival while the framework is disabled. An application + * that registered a store listener first lost its Handoff for good.

+ */ + @EdtTest + public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { + // Deliberately NOT enabled: this is the window the port retains for. + Continuity.disable(); + ContinuityCallback callback = Continuity.callbackForTest(); + + Map info = StateCodec.toMap(fromElsewhere("from the other device", 88L)); + boolean claimed = callback.continuationReceived(Continuity.getActivityType(), info); + + assertFalse(claimed, + "the callback claimed a continuation while continuity was disabled, so the port " + + "discards it and the enable() moments later has nothing to deliver"); + + // And once enabled it IS claimed, or the decline above would just be a feature that never + // works. + Continuity.enable(); + assertTrue(callback.continuationReceived(Continuity.getActivityType(), info), + "an enabled framework refused its own activity type"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From ffd914a898e90a38bf047a4089a0b115d27fcd6b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:06:21 +0300 Subject: [PATCH 072/140] Continuity: the eviction order of the delivery marks survives a restart rememberSeen() writes durableSeen in ITS order, least-recently-seen first, so the file carries the eviction order across a restart. readSeen() then loaded it into a HashMap and threw that away, and enable() replayed an arbitrary order into a map whose whole job is to evict the front. With a full set of 64 marks the next new origin could evict a device the user is actively using instead of the one quiet longest -- and a delayed duplicate from the evicted device then reached the listeners and repeated its side effects. The LRU eviction, the durable write and the trim were each correct. The ordering simply did not survive one hop between them. Removing the last HashMap construction left its import unused, which the forbidden-PMD list fails the build for. The module suite was 6269/6269 green with that sitting there, which is the third time this branch has had a gate catch a consequence no test could see. Full module 6269/6269, SpotBugs 0, forbidden PMD 0 from regenerated reports, copyright, control-character and cast gates clean. Proven by reloading into a HashMap again, which fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 13 +++-- .../continuity/LocalContinuityTest.java | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index d9aa1e7cb52..541bd74112e 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -31,7 +31,6 @@ import com.codename1.ui.Display; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -2248,9 +2247,17 @@ private static void trimTo(Map map) { } } - /// Reads the persisted high-water marks. Never null. + /// Reads the persisted high-water marks, in the order they were written. Never null. + /// + /// LinkedHashMap, and that is the point of it. rememberSeen() writes durableSeen in ITS order, + /// which is least-recently-seen first, so the file carries the eviction order -- and a + /// HashMap here threw that away on the way back in. enable() then replayed an arbitrary order + /// into a map whose whole job is to evict the front, so after a restart with a full set of + /// marks the next new origin could evict a device the user is actively using instead of the + /// one quiet longest, and a delayed duplicate from the evicted device ran its side effects + /// again. private static Map readSeen() { - Map out = new HashMap(); + Map out = new LinkedHashMap(); try { if (!Display.isInitialized() || !Storage.getInstance().exists(PREF_SEEN)) { return out; 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 index 977c4ad6176..d5fe181b5e3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3405,6 +3405,56 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * The eviction order of the delivery marks survives a restart. + * + *

rememberSeen() writes durableSeen in its own order, least-recently-seen first, so the + * file carries the eviction order. Reading it back into a HashMap threw that away, and + * enable() replayed an arbitrary order into a map whose whole job is to evict the front -- so + * the next new origin could evict a device the user is actively using instead of the one + * quiet longest, and a delayed duplicate from the evicted device ran its side effects + * again.

+ */ + @EdtTest + public void theEvictionOrderOfTheMarksSurvivesARestart() { + Continuity.setStateProvider(new RecordingProvider()); + // A full set, acknowledged oldest first, so "device-0" is the one quiet longest. + for (int i = 0; i < 64; i++) { + Map payload = new HashMap(); + payload.put("note", "seen " + i); + Continuity.acknowledge(new AppState() + .setPayload(payload) + .setDeviceId("device-" + i) + .setSequence(i + 1) + .setTimestamp(System.currentTimeMillis())); + } + flushSerialCalls(); + assertEquals(64, Continuity.readSeenForTest().size(), "the fixture is not a full set"); + + // The restart: memory forgotten, the file reloaded. + Continuity.reset(); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.enable(); + + // One more origin, which must evict the eldest and only the eldest. + Map payload = new HashMap(); + payload.put("note", "the new one"); + Continuity.acknowledge(new AppState() + .setPayload(payload) + .setDeviceId("device-new") + .setSequence(99L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + Map after = Continuity.readSeenForTest(); + assertFalse(after.containsKey("device-0"), + "the eldest mark survived, so something else was evicted in its place"); + assertTrue(after.containsKey("device-63"), + "the most recently seen device was evicted instead of the eldest, so a delayed " + + "duplicate from it reaches the listeners and repeats its side effects"); + assertTrue(after.containsKey("device-new"), "the new origin was not recorded at all"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 591fe0033ff2baee0da43e9e69526feaa1d2076d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:28:23 +0300 Subject: [PATCH 073/140] Continuity: logout owes nothing afterwards, and every application callback can end the session Two P1s, both of them a fix of mine stopping one step short. clear() empties the navigation stack and sets clearingStack so that emptying is not treated as the user going somewhere -- but the guard sat AFTER `dirty = true`. So logout left a checkpoint owed, and a flush queued earlier or Android's next suspend then performed it, rebuilding the deleted checkpoint from the still-installed provider and publishing the signed-out account's payload after logout had removed it. The guard now comes first. The lifecycle recheck added two rounds ago covered the LISTENER callback and not the provider. StateProvider.restoreState() is application code too, and discovering that a payload belongs to a signed-out account is exactly what it is for -- so clear() there returned into a restoration that rebuilt the routes and committed, persisting the state clear() had just deleted. Enumerated rather than patched at the reported line. Application code runs at three points on these paths -- after the listeners, inside restore() around restoreState, and inside capture() around saveState -- and every one of them can end the session, so every one now asks afterwards whether it did. saveState was not reported by anybody; a provider that ends the session while being asked what to save must not have that answer stored and published for the account it just signed out of. A fourth came out of the test rather than the report: a failed restore now PARKS the arrival, so aborting inside restore() left dispatch parking the signed-out account's work back into the session clear() had just emptied. Rechecking after the restore closes it. The test caught that one while its own message described the wrong mechanism -- persistence rather than parking. The message now covers both routes, because a test that passes for a reason its text misstates is a test that will mislead the next person to read it. Full module 6271/6271, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 42 +++++++++++++- .../continuity/LocalContinuityTest.java | 57 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 541bd74112e..a259475e95c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -513,12 +513,15 @@ public static void routeStackChanged() { // state it applied. return; } - dirty = true; if (clearingStack) { - // The logout emptying the stack, not the user going anywhere. Checkpointing it would - // write the emptied stack over the state clear() is in the middle of deleting. + // The logout emptying the stack, not the user going anywhere. BEFORE the dirty flag, + // not after it: marking it and then returning left a checkpoint owed, which a flush + // queued earlier -- or Android's next suspend -- then performed, rebuilding the + // deleted checkpoint from the still-installed provider and publishing the signed-out + // account's payload after logout had removed it. return; } + dirty = true; if (!Display.isInitialized() || flushScheduled) { return; } @@ -658,6 +661,7 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile if (!enabled) { return null; } + int lifecycleAtCapture = lifecycle; StateProvider p = provider; AppState state = new AppState(); state.setRoutes(currentRoutes()); @@ -665,6 +669,13 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile Map payload = null; try { payload = p.saveState(); + if (lifecycle != lifecycleAtCapture) { + // Same rule on the way OUT. A provider that ends the session while being + // asked what to save must not then have that answer stored and published for + // the account it just signed out of. + sequenceFailed[0] = true; + return null; + } } 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 @@ -867,10 +878,24 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // payload applied and nothing stored -- so the relay's remaining copy was refused after // the next launch and the state was gone. boolean failed = false; + int lifecycleAtRestore = lifecycle; StateProvider p = provider; if (p != null) { try { p.restoreState(state.getPayload()); + if (lifecycle != lifecycleAtRestore) { + // The provider called clear() or disable(), which is the documented answer to + // "this payload belongs to a signed-out account". Everything below -- + // rebuilding routes, committing, persisting -- would act for a session that + // no longer exists, and would write back the state clear() has just deleted. + // + // The listener callback got this guard already; the provider is the OTHER + // application callback on this path and was missed. Both are places where an + // application is entitled to end the session, so both have to be asked + // afterwards whether it did. + outFailed[0] = true; + return false; + } // An empty payload is not an application. It is what a route-only state carries, // and counting it would make the question above answer yes for every state. applied = !state.getPayload().isEmpty(); @@ -1885,6 +1910,17 @@ private static void dispatch(AppState state) { // Durability is a separate question and has one owner. boolean[] restoreFailed = new boolean[1]; restore(state, restoreFailed); + if (lifecycle != lifecycleAtDispatch) { + // The PROVIDER ended the session while restoring. Parking below would put the + // arrival back into a session that has just been cleared -- getRestorableState() + // would go on offering the signed-out account's work, which is the thing logout + // exists to prevent. + // + // Third place the same question had to be asked: after the listeners, inside + // restore() around the provider, and here. Each is a point where application code + // has just run and may have ended everything. + return; + } if (restoreFailed[0]) { // PARKED, exactly as a deferred arrival is. An automatic restore that failed is // an arrival nobody has dealt with: pollFinished() has already queued a publisher 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 index d5fe181b5e3..cbeb286e872 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3455,6 +3455,63 @@ public void theEvictionOrderOfTheMarksSurvivesARestart() { assertTrue(after.containsKey("device-new"), "the new origin was not recorded at all"); } + /** + * Logout leaves nothing owed, so a later flush cannot rebuild what it deleted. + * + *

clear() empties the navigation stack, and that emptying set the pending flag before the + * guard that was meant to suppress it. A flush queued earlier -- or Android's next suspend -- + * then performed the checkpoint, rebuilding the deleted state from the still-installed + * provider and publishing the signed-out account's payload after logout had removed it.

+ */ + @EdtTest + public void logoutLeavesNothingOwedForALaterFlushToRebuild() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("secret", "the previous account's work"); + Continuity.setStateProvider(provider); + Continuity.checkpoint(); + + Continuity.clear(); + + assertFalse(Continuity.isCheckpointPending(), + "logout left a checkpoint owed, so the next flush writes the signed-out " + + "account's payload back over the state it just deleted"); + // And nothing a later flush could do brings it back. + flushSerialCalls(); + assertNull(Continuity.getRestorableState(), + "a flush after logout restored the deleted checkpoint"); + } + + /** + * A provider that ends the session while restoring stops the restoration. + * + *

Detecting that a payload belongs to a signed-out account is exactly what this callback is + * for, and clear() is the documented response. Restoration carried on regardless: it rebuilt + * the routes and committed, persisting the state clear() had just deleted. The listener + * callback had this guard; the provider is the other application callback on the path.

+ */ + @EdtTest + public void aProviderThatLogsOutWhileRestoringStopsTheRestore() { + Continuity.setAutoRestore(true); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + // "This payload is not the account that is signed in." + Continuity.clear(); + } + }); + + Continuity.deliver(fromElsewhere("the previous account's work", 77L)); + flushSerialCalls(); + + assertNull(Continuity.getRestorableState(), + "the arrival the provider logged out over is still on offer -- either persisted " + + "past the clear() or parked back into the session it just emptied, and " + + "both hand the signed-out account's work to whoever signs in next"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From ec5fa36973d4cf638bbf777d61db54922c5704e1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:00:39 +0300 Subject: [PATCH 074/140] Continuity: a route that logs out while being rebuilt stops the restore Last round claimed the application-callback sites on this path had been enumerated -- the listener, restoreState, saveState. That enumeration was of the callbacks the FRAMEWORK invokes directly, and rebuilding a route stack reaches application code indirectly, through Navigation: the route factory, the form's constructor, whatever its show callback does. Any of it may find the session expired and call clear(). Indirect is the same risk, and the list was of the wrong thing. Refusing to commit turned out to be half of it, which the test found rather than the report: restoreStack() has already rebuilt the stack by the time control returns here, so the signed-out account's screens were back in the navigation history even with nothing written to storage. The abort empties it too -- suppressed while doing so, or the emptying schedules a checkpoint and recreates exactly what the logout removed. The other finding in this batch asked for a lock, or for cross-thread marshalling, inside the framework. Refused: Codename One is single threaded by design -- one thread on each side of a native boundary, marshalled at the boundary rather than locked -- and this codebase does not have locks to be consistent with. The real gap it exposed is that SyncedStore documented no threading contract at all while the bridge behind it says in its own comment that it is EDT-owned. That contract is now stated on the public class, including what the simulation's key index would actually do with two concurrent writers -- each reads it, adds one key, writes it back, and one key vanishes from keys() while its value stays readable by name -- and why the answer is the toolkit's threading model rather than a lock inside it. The platform stores have no such structure and no such exposure; this is a simulator-shaped hazard being answered where an application can read about it. Full module 6272/6272, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. The guard is proven by disabling THAT check alone, leaving the earlier provider one in place. The first attempt at this gate run was killed partway. It is re-run rather than reported from, because an interrupted run is not a passing one. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 28 ++++++++++++ .../continuity/sync/SyncedStore.java | 13 ++++++ .../continuity/LocalContinuityBridge.java | 6 +++ .../continuity/LocalContinuityTest.java | 43 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index a259475e95c..e4224ed754a 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -937,6 +937,34 @@ private static boolean restore(final AppState state, boolean[] outFailed) { } finally { applyingRestore = false; } + if (lifecycle != lifecycleAtRestore) { + // Rebuilding a route stack RUNS APPLICATION CODE -- the route factory, the form's + // constructor, whatever its show callback does -- and any of it may discover that the + // session is over and call clear() or disable(). Committing after that repopulates + // both the navigation stack and the stored checkpoint with the signed-out account's + // state. + // + // The fourth site on this path, and the one the last round's enumeration missed: it + // listed the callbacks the framework invokes DIRECTLY -- the listener, restoreState, + // saveState -- and route dispatch reaches application code indirectly, through + // Navigation. Indirect is the same risk; the enumeration was of the wrong thing. + // + // The STACK is emptied again as well, which refusing to commit does not do on its + // own: restoreStack() had already rebuilt it before returning here, so the signed-out + // account's screens were back in the history even with nothing written to storage. + // Suppressed while doing it, or the emptying schedules a checkpoint of its own and + // recreates exactly what the logout removed. + try { + clearingStack = true; + Navigation.clearStack(); + } catch (Throwable t) { + Log.e(t); + } finally { + clearingStack = false; + } + outFailed[0] = true; + return false; + } if (!shown && !applied) { // Routes were named, none could be rebuilt, and nothing else in the state applied // either -- an attempt that failed outright, so it stays on the relay for a launch diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 9a0a542ca69..316b9dbd37b 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -63,6 +63,19 @@ /// should not have to arrange an entitlement to get it. Where the platform has no such store -- /// Android, desktop, the browser -- `isSupported()` is false and every call here is an inert /// no-op, so the sensible shape is a synced value with a local default behind it. +/// #### Threading +/// +/// Called on the event dispatch thread, like the rest of the toolkit. Codename One is single +/// threaded by design -- one thread on each side of a native boundary, marshalled at the boundary +/// rather than locked -- and this class follows that rule rather than making an exception to it. +/// +/// It is worth stating because the simulation behind `isSupported() == true` on a desktop keeps +/// its key index as a second stored value: two threads writing different NEW keys at once would +/// each read that index, add their own key, and write it back, so one of them would vanish from +/// `keys()` while its value stayed readable by name. The platform stores have no such structure +/// and no such exposure. The answer is the toolkit's answer everywhere else -- call it from the +/// event thread, and use `com.codename1.ui.Display#callSerially(Runnable)` if you are on another +/// one -- not a lock inside a framework that does not have them. public final class SyncedStore { private static final List listeners = new ArrayList(); diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 5b962ebb4ec..5f9ccb3e692 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -72,6 +72,12 @@ public class LocalContinuityBridge implements ContinuityBridge { // EDT-owned. Everything here runs on the Codename One event thread: the framework calls in // from there, and the simulator's "Simulate ->" items reach this class through // SimulatorHookLoader, which dispatches every hook with Display.callSeriallyAndWait. + // + // The synced store below rests on the same assumption, and SyncedStore now says so where an + // application can read it: the key index is a second stored value, so two threads writing + // different new keys would each read it, add one key, and write it back over the other. That + // is answered by the toolkit's threading model rather than by a lock in it -- see the + // Threading section on SyncedStore. private ContinuityCallback callback; private String publishedType; private String publishedTitle; 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 index cbeb286e872..f64f5917e37 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3512,6 +3512,49 @@ public void restoreState(Map payload) { + "both hand the signed-out account's work to whoever signs in next"); } + /** + * A route that logs out while being rebuilt stops the restore. + * + *

Rebuilding a stack runs application code -- the route factory, the form's constructor, + * its show callback -- and any of it may discover the session is over. Committing afterwards + * repopulates both the navigation stack and the stored checkpoint with the signed-out + * account's state.

+ * + *

This is the indirect case. The framework never calls the route factory; Navigation does, + * on its behalf, which is why an enumeration of the callbacks the framework invokes directly + * did not find it.

+ */ + @EdtTest + public void aRouteThatLogsOutWhileBeingRebuiltStopsTheRestore() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(true); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String path) { + // "The session behind this route has expired." + Continuity.clear(); + return new Form(path); + } + }); + try { + AppState arrival = fromElsewhere("the previous account's screen", 66L); + List routes = new ArrayList(); + routes.add("/account/statement"); + arrival.setRoutes(routes); + + Continuity.deliver(arrival); + flushSerialCalls(); + + assertNull(Continuity.getRestorableState(), + "the restore committed after the route logged out, so the signed-out " + + "account's stack and checkpoint are back"); + assertTrue(Navigation.getStack().isEmpty(), + "the rebuilt stack survived the logout that happened while building it"); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 0d7c97123a3a77777e9d3065f8ed52e44e3281ae Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:37:54 +0300 Subject: [PATCH 075/140] Continuity: the fetch worker confirms its session too, and a per-request redirect choice is honoured The publish worker has confirmed its session on the event thread since the first round of this review. The poll worker never did, so clear() or setRelay() landing between its creation and its first instruction rejected only the COMPLETION -- after the read had gone out. A relay that resolves authentication inside fetch(), which RestStateRelay does, would issue a request after logout and could present the next account's credentials to the previous endpoint, while clear() promises that only a request already on the wire survives it. The exact twin of the first P1 on this branch, left unfixed because that one was reported and this one was not. The RequestBuilder passthrough added last round was also wrong: `if (!followRedirects)` meant an explicit followRedirects(true) could never override a global setDefaultFollowRedirects(false), which is the opposite of what a per-request setting is for. It is three-state now -- unspecified leaves the global default alone in either direction, and only an explicit call overrides it. The probe of that fix PASSED at first, and the reason is worth recording: it asserted on the builder's own field, which is recorded either way, while the defect was in applying it to the request. The same wrong-layer mistake as the earlier test that watched isCheckpointPending() when the observable was what reached the relay. The test now reflects into createRequest and asserts on the built ConnectionRequest, where it fails against the broken code. An existing test changed with it and got more accurate: it asserted that an ordinary request "follows redirects", where the real contract is that it carries NO setting of its own and inherits whatever the application chose globally. Asserting true there would have pinned the wrong behaviour for anyone who turns redirects off across their app. Full module 6274/6274, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 24 +++++ .../com/codename1/io/rest/RequestBuilder.java | 18 +++- .../continuity/LocalContinuityTest.java | 95 +++++++++++++++++-- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e4224ed754a..6bb8d678a58 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1046,6 +1046,30 @@ private static void startPoll() { Display.getInstance().startThread(new Runnable() { @Override public void run() { + // The SAME preflight the publish worker does, and it was missing here. A worker + // is created on the event thread and runs later, so clear() or setRelay() can + // land in between: only the COMPLETION was rejected, after the read had already + // gone out. A custom relay that resolves authentication inside fetch() would + // therefore issue a request after logout, and could present the next account's + // credentials to the previous endpoint -- while clear() promises that only a + // request already on the wire survives it. + // + // Asked on the event thread because relaySession belongs to it, and blocking a + // worker on the EDT is the safe direction: the EDT never waits on a worker. + final boolean[] stillOurs = new boolean[1]; + try { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + stillOurs[0] = session == relaySession; + } + }); + } catch (Throwable t) { + Log.e(t); + } + if (!stillOurs[0]) { + return; + } // Off the EDT because fetch() blocks, and touching NOTHING: the relay came in as // a local and the answer goes back through the event queue. AppState fetched = null; diff --git a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java index 4534e40905f..1e3f079f73a 100644 --- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java +++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java @@ -80,8 +80,11 @@ public class RequestBuilder { private ErrorCodeHandler jsonErrorCallback; private ErrorCodeHandler stringErrorCallback; - /// Whether a redirect may be followed. True to match ConnectionRequest's own default. - private boolean followRedirects = true; + /// Whether a redirect may be followed, or NULL when the caller has not said. + /// + /// Three states rather than two: unspecified has to leave ConnectionRequest's global default + /// alone, and only an explicit call may override it -- in either direction. + private Boolean followRedirects; private ErrorCodeHandler propertyErrorCallback; private Class errorHandlerPropertyType; //private ActionListener errorCallback; @@ -443,7 +446,7 @@ public RequestBuilder onErrorCode(ErrorCodeHandler err, /// RequestBuilder instance public RequestBuilder followRedirects(boolean follow) { checkFetched(); - followRedirects = follow; + followRedirects = Boolean.valueOf(follow); return this; } @@ -1071,8 +1074,13 @@ private Connection createRequest(boolean parseJson) { req.setContentType(contentType); } req.setFailSilently(hasErrorCodeHandler()); - if (!followRedirects) { - req.setFollowRedirects(false); + if (followRedirects != null) { + // Only when the CALLER said so. Applying the field unconditionally would push this + // builder's default over ConnectionRequest.setDefaultFollowRedirects for every + // request that never asked, and testing it as a plain boolean made an explicit + // followRedirects(true) unreachable whenever the global default was false -- which is + // the opposite of a per-request setting. + req.setFollowRedirects(followRedirects.booleanValue()); } if (cache != null) { req.setCacheMode(cache); diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java index f64f5917e37..c44d5850ee7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -26,6 +26,7 @@ import com.codename1.continuity.sync.SyncedStore; import com.codename1.continuity.sync.SyncedStoreListener; import com.codename1.impl.continuity.LocalContinuityBridge; +import com.codename1.io.ConnectionRequest; import com.codename1.io.Storage; import com.codename1.io.rest.RequestBuilder; import com.codename1.io.rest.Rest; @@ -3257,18 +3258,55 @@ public void theRelayRefusesRedirectsOnItsAuthenticatedRequests() throws Exceptio RequestBuilder built = (RequestBuilder) auth.invoke( relay, Rest.post("https://example.invalid/continuity")); - assertFalse(followsRedirects(built), + assertEquals(Boolean.FALSE, redirectSetting(built), "the relay's requests follow redirects, so a 307 forwards the bearer token and " + "the state to whatever host the endpoint names"); - assertTrue(followsRedirects(Rest.post("https://example.invalid/continuity")), - "an ordinary request stopped following redirects, which is a change to every " - + "caller rather than to this one"); + // UNSPECIFIED, not "true". The setting is three-state on purpose: a request that never + // asked must leave ConnectionRequest's global default alone, in either direction, so + // asserting true here would have pinned the wrong contract -- an application that turned + // redirects off globally would still get them. + assertNull(redirectSetting(Rest.post("https://example.invalid/continuity")), + "an ordinary request now carries a redirect setting of its own, which overrides " + + "whatever the application chose globally"); } - private static boolean followsRedirects(RequestBuilder b) throws Exception { + /** + * An explicit followRedirects(true) reaches the request even when the global default is false. + * + *

Asserted on the built {@code ConnectionRequest}, not on the builder's own field: the + * field is recorded either way, and the defect was in applying it. A first version of this + * check read the builder and passed against the broken code, which is the same wrong-layer + * mistake the probe exists to catch.

+ */ + @EdtTest + public void anExplicitRedirectChoiceReachesTheRequest() throws Exception { + boolean previous = ConnectionRequest.isDefaultFollowRedirects(); + ConnectionRequest.setDefaultFollowRedirects(false); + try { + assertTrue(builtFollowsRedirects( + Rest.post("https://example.invalid/x").followRedirects(true)), + "an explicit followRedirects(true) did not reach the request, so a per-request " + + "setting cannot override the global one"); + assertFalse(builtFollowsRedirects(Rest.post("https://example.invalid/x")), + "a request that never asked stopped inheriting the global default"); + } finally { + ConnectionRequest.setDefaultFollowRedirects(previous); + } + } + + /// What the builder actually hands to the network layer. + private static boolean builtFollowsRedirects(RequestBuilder b) throws Exception { + java.lang.reflect.Method m = + RequestBuilder.class.getDeclaredMethod("createRequest", boolean.class); + m.setAccessible(true); + return ((ConnectionRequest) m.invoke(b, Boolean.FALSE)).isFollowRedirects(); + } + + /// The builder's redirect setting: TRUE, FALSE, or null for "the caller did not say". + private static Boolean redirectSetting(RequestBuilder b) throws Exception { java.lang.reflect.Field f = RequestBuilder.class.getDeclaredField("followRedirects"); f.setAccessible(true); - return ((Boolean) f.get(b)).booleanValue(); + return (Boolean) f.get(b); } /** @@ -3555,6 +3593,51 @@ public Form dispatch(String path) { } } + /** + * A logout stops a fetch that has not reached the network yet. + * + *

The publish worker has confirmed its session on the event thread since the first round of + * this review; the poll worker never did, so only its COMPLETION was rejected -- after the + * read had gone out. A custom relay that resolves authentication inside fetch() would issue a + * request after logout, and could present the next account's credentials to the previous + * endpoint, while clear() promises that only a request already on the wire survives it.

+ */ + @EdtTest + public void aLogoutStopsAFetchThatHasNotReachedTheNetwork() { + Continuity.setStateProvider(new RecordingProvider()); + final java.util.concurrent.atomic.AtomicInteger reads = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.CountDownLatch letGo = + new java.util.concurrent.CountDownLatch(1); + + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + } + + public AppState fetch() { + // The worker reaches here only if the session check let it through. Waiting first + // so the test can log out while it is still queued. + try { + letGo.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + reads.incrementAndGet(); + return null; + } + }); + + // The worker is created and queued; the logout lands before it runs. + Continuity.clear(); + letGo.countDown(); + pause(500L); + flushSerialCalls(); + + assertEquals(0, reads.get(), + "a relay read went out after logout, so a relay that resolves its credentials " + + "inside fetch() presents the next account's to the previous endpoint"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 6e3ee63c3b87b53e0e7ee43168b7038df28725cb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:09:53 +0300 Subject: [PATCH 076/140] Continuity: a truncated relay document is a failed read, not an empty relay JSONParser does NOT throw on malformed input. handleParsingException logs the failure, closes the reader, and returns whatever partial map it had built -- so fromJson's documented IOException never fired for truncation, which is the corruption a network actually produces. Three correct layers then compounded it into the worst outcome this feature has. A document cut off after "device" and "seq" carries no routes and no payload, which is an EMPTY state, which this framework reads as a TOMBSTONE. So a truncated response meant: the other device is recorded as having cleared its work, that mark is made durable, fetch() reports a SUCCESSFUL read, and the queued POST is released over the relay's real document. Network corruption became deliberate-looking data loss, in both directions at once. The check is structural rather than a second parse. It answers one question -- did the whole document arrive -- which is exactly what truncation breaks, and by not reading values it cannot disagree with the parser about what they mean. The simulated store also stops leaving a value behind when its index cannot be written. Reporting false while the value stayed durable made the answer wrong the other way round: the caller takes its documented fallback path while get() returns the value it was told had failed, and neither keys() nor clearing the store can reach it. A failed write now leaves nothing, which is the only answer that means one thing. Full module 6276/6276, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, copyright, control-character and cast gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 60 ++++++++++++++++++ .../continuity/LocalContinuityBridge.java | 16 ++++- .../continuity/LocalContinuityTest.java | 61 +++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 1788a849bf9..fcb9b400d4e 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -209,9 +209,69 @@ public static AppState fromJson(String json) throws IOException { if (json == null || json.trim().length() == 0) { return null; } + if (!isCompleteObject(json)) { + // JSONParser does NOT throw on a malformed document: it logs the failure, closes the + // reader, and returns whatever partial map it had built. So a truncated relay response + // came back as a valid-looking state, and the shape it takes is the worst one -- a + // document cut off after "device" and "seq" has no routes and no payload, which is an + // EMPTY state, which this framework reads as a tombstone. The origin is then recorded + // as having cleared its work, durably, and fetch() reports a SUCCESSFUL read, which + // releases a queued POST over the relay's real document. + // + // So the check is here rather than left to the parser. Truncation is the corruption + // that actually happens on a network, and it is exactly what a structural scan + // catches: a document whose braces and brackets do not close, or that ends inside a + // string, was not received whole. + throw new IOException("The continuity relay returned a document that is not a " + + "complete JSON object. Treated as a failed read rather than as an empty " + + "relay, because a truncated document is indistinguishable from one that " + + "says the other device has nothing."); + } return fromMap(JSONParser.parseJSON(json)); } + /// Whether `json` is one complete JSON object: it starts with `{`, ends with its match, and + /// closes every string, brace and bracket in between. + /// + /// Deliberately structural rather than a full parse. It has one job -- deciding whether the + /// whole document arrived -- and answering it does not need the values, so it cannot disagree + /// with the parser about what they mean. + static boolean isCompleteObject(String json) { + String trimmed = json.trim(); + if (trimmed.length() < 2 || trimmed.charAt(0) != '{' + || trimmed.charAt(trimmed.length() - 1) != '}') { + return false; + } + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + continue; + } + if (c == '"') { + inString = true; + } else if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + if (depth < 0) { + // A closer with nothing open: the document is not merely short, it is wrong. + return false; + } + } + } + return depth == 0 && !inString; + } + /// 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. /// diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 5f9ccb3e692..e333c4494a8 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -208,9 +208,19 @@ public boolean syncedStorePut(String key, String value) { if (!keys.contains(key)) { keys.add(key); if (!writeIndex(keys)) { - // The value is stored and the index is not, so keys() would not list it. Reported - // rather than hidden: a caller told the write succeeded expects to find it again - // by enumeration as well as by name. + // The value is stored and the index is not, so keys() would not list it. Rolled + // BACK rather than merely reported: leaving it made "false" a lie in the other + // direction -- the caller takes its documented fallback path while get() returns + // the value it was told had failed, keys() omits it, and clearing the store + // cannot reach it. + // + // A failed write should leave nothing behind, which is the only answer that means + // one thing. + try { + Storage.getInstance().deleteStorageFile(storageName(key)); + } catch (Throwable t) { + Log.e(t); + } return false; } } 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 index c44d5850ee7..6cddd283974 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3638,6 +3638,67 @@ public AppState fetch() { + "inside fetch() presents the next account's to the previous endpoint"); } + /** + * A truncated relay document is a failed read, not an empty relay. + * + *

JSONParser does not throw on malformed input: it logs and returns the partial map it had + * built. A document cut off after "device" and "seq" therefore parsed into a state with no + * routes and no payload -- which this framework reads as a TOMBSTONE -- so the origin was + * recorded as having cleared its work, durably, while fetch() reported a successful read and + * released a queued POST over the relay's real document.

+ */ + @EdtTest + public void aTruncatedRelayDocumentIsAFailedReadNotAnEmptyRelay() { + String whole = StateCodec.toJson(fromElsewhere("what the other device was doing", 42L)); + assertTrue(whole.length() > 20, "the fixture document is too short to truncate usefully"); + String cut = whole.substring(0, whole.length() / 2); + + // The control: the whole document still reads. + try { + assertNotNull(StateCodec.fromJson(whole), "a complete document failed to parse"); + } catch (java.io.IOException e) { + fail("a complete document was refused: " + e.getMessage()); + } + + try { + AppState partial = StateCodec.fromJson(cut); + fail("a truncated document was accepted" + + (partial != null && partial.isEmpty() + ? " as an EMPTY state, which is read as a tombstone: the origin is " + + "recorded as having cleared its work and the relay's real " + + "document is then overwritten" + : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().contains("complete JSON object"), + expected.getMessage()); + } + } + + /** + * A simulated-store write that could not be indexed leaves nothing behind. + * + *

Returning false while the value stayed durable made the answer a lie in the other + * direction: the caller takes its documented fallback path while get() returns the value it + * was told had failed, keys() omits it, and clearing the store cannot reach it.

+ */ + @EdtTest + public void aStoreWriteThatCouldNotBeIndexedLeavesNothingBehind() { + Storage original = Storage.getInstance(); + Storage.setStorageInstance(new RefusingOneStorage(original, "CN1$SyncedStoreKeys")); + boolean reported; + try { + reported = SyncedStore.put("draft", "half a sentence"); + } finally { + Storage.setStorageInstance(original); + } + + assertFalse(reported, "the write was reported as successful although the index refused"); + assertEquals("missing", SyncedStore.get("draft", "missing"), + "the value the caller was told had FAILED is readable, so the application took " + + "its fallback path over a value that is really there -- and keys() and " + + "clearing cannot see it"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From b8c26cdb171f6c709198408a4da3b0c179a752a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:05:06 +0300 Subject: [PATCH 077/140] Continuity: validate relay JSON properly, hold the publisher through coalesced reads, roll back an unshowable stack The structural check added last round was not enough, and the finding is right about why: counting braces and closing strings catches a document cut in half and lets {"device":"d","seq":"2","payload":tru} through -- balanced, quoted, invalid. JSONParser answers a bad token exactly as it answers truncation, by logging and returning the partial map, so the same tombstone-and-overwrite chain follows. It is a real grammar check now. That was the only option: the parser has no way to report a failure -- its handler logs, closes the reader, and returns what it had -- so nothing else could tell a valid document from a partial one. It builds nothing and interprets no value, so it cannot disagree with the parser about meaning; it answers only whether the whole document is well formed. Its own test caught the validator being laxer than JSON on numbers: a permissive digit loop accepted "01". Being laxer than the grammar is the exact failure this exists to correct, so it does not get to keep a small version of it. A tombstone no longer releases the publisher while a coalesced read is owed. pollFinished() clears `polling` before handing the state to admit(), so releasing there started the POST BEFORE the follow-up GET and then ran the two together against a relay that holds one document -- the remote update the second read was going to see is overwritten, and that read returns this device's own echo. And restoreStack() keeps the previous stack until the new one is really on screen. show() runs application code; when it threw, the stack had already been replaced, so the old form stayed up while getCurrent(), back() and the next checkpoint described a stack the user never saw. That rollback was written as a catch around a generic list read, and check-cast-semantics.sh refused it by name: reading from a generic list compiles to a checkcast, this virtual machine's CHECKCAST expands to nothing, and a handler catching RuntimeException around one can never run. The form is resolved before the try so the guarded region holds only the call being guarded. Fourth gate finding on this branch that a green suite could not have shown. The tombstone test took three attempts and the probe rejected the first two. Delivering the tombstone by hand left `polling` true, so startPublisher() stopped at its own guard and the test passed against the unfixed code; answering with it on the first read admitted it before the arrival could park, so there was nothing to supersede. It now arrives from a held second read, with another asked for while that one is in flight. Full module 6279/6279, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics back to its 191 baseline, copyright and control-character gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 15 +- .../com/codename1/continuity/StateCodec.java | 257 +++++++++++++++--- .../src/com/codename1/router/Navigation.java | 25 +- .../continuity/LocalContinuityTest.java | 207 +++++++++++++- 4 files changed, 463 insertions(+), 41 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6bb8d678a58..5dd768133fe 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1848,7 +1848,20 @@ private static void admit(final AppState state) { if (waiting != null && state.getDeviceId().equals(waiting.getDeviceId()) && waiting.getSequence() <= state.getSequence()) { parked = null; - startPublisher(); + if (pollAgain) { + // A coalesced read is still owed. pollFinished() has already cleared + // `polling`, so releasing the publisher here would start the POST BEFORE that + // follow-up GET and then run the two together -- against a relay that holds + // one document, which is the situation the one-fetch-at-a-time rule exists + // for. The remote update the second read was going to see gets overwritten, + // and the read comes back with this device's own echo. + // + // Left owed instead: whoever finishes the coalesced read releases it, which + // is the same path every other hold uses. + publishRequested = true; + } else { + startPublisher(); + } } // Durably, and here rather than through commit(). Consuming a tombstone is the one // arrival that CANNOT fail -- there is no payload to hand over and no route to diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index fcb9b400d4e..0a2c7cad05f 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -209,7 +209,7 @@ public static AppState fromJson(String json) throws IOException { if (json == null || json.trim().length() == 0) { return null; } - if (!isCompleteObject(json)) { + if (!isValidJsonObject(json)) { // JSONParser does NOT throw on a malformed document: it logs the failure, closes the // reader, and returns whatever partial map it had built. So a truncated relay response // came back as a valid-looking state, and the shape it takes is the worst one -- a @@ -220,56 +220,241 @@ public static AppState fromJson(String json) throws IOException { // // So the check is here rather than left to the parser. Truncation is the corruption // that actually happens on a network, and it is exactly what a structural scan - // catches: a document whose braces and brackets do not close, or that ends inside a - // string, was not received whole. + // catches -- and so is a bad token, which a structural scan alone let through: + // {"device":"d","seq":"2","payload":tru} is balanced, quoted, and invalid. throw new IOException("The continuity relay returned a document that is not a " - + "complete JSON object. Treated as a failed read rather than as an empty " + + "valid JSON object. Treated as a failed read rather than as an empty " + "relay, because a truncated document is indistinguishable from one that " + "says the other device has nothing."); } return fromMap(JSONParser.parseJSON(json)); } - /// Whether `json` is one complete JSON object: it starts with `{`, ends with its match, and - /// closes every string, brace and bracket in between. - /// - /// Deliberately structural rather than a full parse. It has one job -- deciding whether the - /// whole document arrived -- and answering it does not need the values, so it cannot disagree - /// with the parser about what they mean. - static boolean isCompleteObject(String json) { - String trimmed = json.trim(); - if (trimmed.length() < 2 || trimmed.charAt(0) != '{' - || trimmed.charAt(trimmed.length() - 1) != '}') { + /// Whether `json` is ONE syntactically valid JSON object and nothing else. + /// + /// A real grammar check, because a structural one was not enough. Counting braces and closing + /// strings catches a document cut in half, and lets + /// `{"device":"d","seq":"2","payload":tru}` through -- balanced, quoted, and invalid. The + /// parser then logs the bad token and returns the map it had built up to that point, which is + /// a partial state with the same consequences as a truncated one. + /// + /// Nothing is built here and no value is interpreted: this answers only "is the whole of this + /// document well formed", so it cannot disagree with the parser about what anything MEANS. + /// The alternative was asking the parser, and it has no way to say -- its exception handler + /// logs, closes the reader, and returns the partial result. + static boolean isValidJsonObject(String json) { + String t = json.trim(); + if (t.length() < 2 || t.charAt(0) != '{') { return false; } - int depth = 0; - boolean inString = false; - boolean escaped = false; - for (int i = 0; i < trimmed.length(); i++) { - char c = trimmed.charAt(i); - if (inString) { - if (escaped) { - escaped = false; - } else if (c == '\\') { - escaped = true; - } else if (c == '"') { - inString = false; - } - continue; + int[] at = new int[1]; + if (!scanValue(t, at)) { + return false; + } + skipWhitespace(t, at); + // Trailing content is not a second document, it is a broken one. + return at[0] == t.length(); + } + + private static void skipWhitespace(String s, int[] at) { + while (at[0] < s.length()) { + char c = s.charAt(at[0]); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return; + } + at[0]++; + } + } + + private static boolean scanValue(String s, int[] at) { + skipWhitespace(s, at); + if (at[0] >= s.length()) { + return false; + } + char c = s.charAt(at[0]); + if (c == '{') { + return scanObject(s, at); + } + if (c == '[') { + return scanArray(s, at); + } + if (c == '"') { + return scanString(s, at); + } + if (c == 't') { + return scanLiteral(s, at, "true"); + } + if (c == 'f') { + return scanLiteral(s, at, "false"); + } + if (c == 'n') { + return scanLiteral(s, at, "null"); + } + return scanNumber(s, at); + } + + private static boolean scanObject(String s, int[] at) { + at[0]++; + skipWhitespace(s, at); + if (at[0] < s.length() && s.charAt(at[0]) == '}') { + at[0]++; + return true; + } + for (;;) { + skipWhitespace(s, at); + if (at[0] >= s.length() || s.charAt(at[0]) != '"' || !scanString(s, at)) { + return false; + } + skipWhitespace(s, at); + if (at[0] >= s.length() || s.charAt(at[0]) != ':') { + return false; + } + at[0]++; + if (!scanValue(s, at)) { + return false; + } + skipWhitespace(s, at); + if (at[0] >= s.length()) { + return false; + } + char c = s.charAt(at[0]); + at[0]++; + if (c == '}') { + return true; + } + if (c != ',') { + return false; + } + } + } + + private static boolean scanArray(String s, int[] at) { + at[0]++; + skipWhitespace(s, at); + if (at[0] < s.length() && s.charAt(at[0]) == ']') { + at[0]++; + return true; + } + for (;;) { + if (!scanValue(s, at)) { + return false; + } + skipWhitespace(s, at); + if (at[0] >= s.length()) { + return false; + } + char c = s.charAt(at[0]); + at[0]++; + if (c == ']') { + return true; } + if (c != ',') { + return false; + } + } + } + + private static boolean scanString(String s, int[] at) { + at[0]++; + while (at[0] < s.length()) { + char c = s.charAt(at[0]); + at[0]++; if (c == '"') { - inString = true; - } else if (c == '{' || c == '[') { - depth++; - } else if (c == '}' || c == ']') { - depth--; - if (depth < 0) { - // A closer with nothing open: the document is not merely short, it is wrong. + return true; + } + if (c == '\\') { + if (at[0] >= s.length()) { + return false; + } + char e = s.charAt(at[0]); + at[0]++; + if (e == 'u') { + if (at[0] + 4 > s.length()) { + return false; + } + for (int i = 0; i < 4; i++) { + if (hexValue(s.charAt(at[0] + i)) < 0) { + return false; + } + } + at[0] += 4; + } else if ("\"\\/bfnrt".indexOf(e) < 0) { return false; } } } - return depth == 0 && !inString; + return false; + } + + private static int hexValue(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + private static boolean scanLiteral(String s, int[] at, String literal) { + if (!s.startsWith(literal, at[0])) { + return false; + } + at[0] += literal.length(); + return true; + } + + private static boolean scanNumber(String s, int[] at) { + int start = at[0]; + if (at[0] < s.length() && s.charAt(at[0]) == '-') { + at[0]++; + } + // JSON's integer rule exactly: a single 0, or a non-zero digit and any digits after it. + // A permissive loop accepted "01", which is not JSON -- and being laxer than the grammar + // is the whole failure this validator exists to correct, so it does not get to make its + // own small version of it. + if (at[0] >= s.length()) { + return false; + } + char first = s.charAt(at[0]); + if (first == '0') { + at[0]++; + } else if (first >= '1' && first <= '9') { + while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') { + at[0]++; + } + } else { + return false; + } + if (at[0] < s.length() && s.charAt(at[0]) == '.') { + at[0]++; + int frac = 0; + while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') { + at[0]++; + frac++; + } + if (frac == 0) { + return false; + } + } + if (at[0] < s.length() && (s.charAt(at[0]) == 'e' || s.charAt(at[0]) == 'E')) { + at[0]++; + if (at[0] < s.length() && (s.charAt(at[0]) == '+' || s.charAt(at[0]) == '-')) { + at[0]++; + } + int exp = 0; + while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') { + at[0]++; + exp++; + } + if (exp == 0) { + return false; + } + } + return at[0] > start; } /// Throws when any value in the map could not survive being written to a property list, sent diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 8f2d915a9de..d7c8a830893 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -236,11 +236,30 @@ public static boolean restoreStack(List paths) { if (rebuilt.isEmpty()) { return false; } + // The PREVIOUS stack is kept until the new one is really on screen. show() runs + // application code -- the form's own show handling, and whatever listens to it -- and if + // that throws, the old form is still displayed while getCurrent(), back() and the next + // checkpoint would all be describing a stack the user never saw. A later navigation then + // persists a restoration that failed. + List previous = new ArrayList(stack); stack.clear(); stack.addAll(rebuilt); - // 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(); + // Resolved BEFORE the try, and that is not tidying. Reading from a generic list compiles + // to a checkcast, and this virtual machine's CHECKCAST expands to nothing -- so a failed + // cast hands the wrong object to the next instruction instead of throwing, and a handler + // that catches RuntimeException around it is a handler that can never run. The guarded + // region has to contain only the call being guarded. check-cast-semantics.sh refuses the + // other shape, correctly, and caught this exact line. + Form top = rebuilt.get(rebuilt.size() - 1).getForm(); + try { + // show(), not showBack(): the user is arriving, not going back, and showBack would + // run the reverse transition into a screen they have not seen yet. + top.show(); + } catch (RuntimeException e) { + stack.clear(); + stack.addAll(previous); + throw e; + } stackChanged(); return true; } 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 index 6cddd283974..4a16938f944 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3669,7 +3669,7 @@ public void aTruncatedRelayDocumentIsAFailedReadNotAnEmptyRelay() { + "document is then overwritten" : "")); } catch (java.io.IOException expected) { - assertTrue(expected.getMessage().contains("complete JSON object"), + assertTrue(expected.getMessage().contains("valid JSON object"), expected.getMessage()); } } @@ -3699,6 +3699,211 @@ public void aStoreWriteThatCouldNotBeIndexedLeavesNothingBehind() { + "clearing cannot see it"); } + /** + * A syntactically invalid document is refused even when its delimiters balance. + * + *

The first version of this guard counted braces and closed strings, which catches a + * document cut in half and lets a bad TOKEN through -- and the parser answers a bad token the + * same way it answers truncation: it logs, and returns the map it had built so far. A partial + * state that looks like a tombstone has the same consequences either way.

+ */ + @EdtTest + public void aSyntacticallyInvalidDocumentIsRefusedEvenWhenBalanced() throws Exception { + // Balanced braces, closed strings, invalid: "tru" is not a token. + String balancedButInvalid = "{\"device\":\"d\",\"seq\":\"2\",\"payload\":tru}"; + assertFalse(StateCodec.isValidJsonObject(balancedButInvalid), + "a bad token passed the check, so the parser's partial map becomes a state"); + + // The shapes that must still be accepted, or the check is just breaking the feature. + assertTrue(StateCodec.isValidJsonObject( + StateCodec.toJson(fromElsewhere("a real one", 3L))), + "a document this codec itself wrote was refused"); + assertTrue(StateCodec.isValidJsonObject("{}"), "an empty object was refused"); + assertTrue(StateCodec.isValidJsonObject( + "{\"a\":[1,-2.5e3,true,null,{\"b\":\"\\u00e9\"}]}"), + "a valid nested document was refused"); + + // And the shapes that must not be. + assertFalse(StateCodec.isValidJsonObject("{\"a\":1,}"), "a trailing comma was accepted"); + assertFalse(StateCodec.isValidJsonObject("{\"a\":1} junk"), + "trailing content after the object was accepted"); + assertFalse(StateCodec.isValidJsonObject("{\"a\":\"unterminated}"), + "an unterminated string was accepted"); + assertFalse(StateCodec.isValidJsonObject("{\"a\":01}"), + "a malformed number was accepted"); + assertFalse(StateCodec.isValidJsonObject("{\"a\":\"\\uZZZZ\"}"), + "a bad unicode escape was accepted"); + } + + /** + * A tombstone does not release the publisher while a coalesced read is still owed. + * + *

pollFinished() clears {@code polling} before the tombstone is handled, so releasing here + * started the POST BEFORE the follow-up GET and then ran the two together -- against a relay + * that holds one document, which is exactly what the one-fetch-at-a-time rule exists to + * prevent. The remote update the second read was going to see is overwritten, and that read + * comes back with this device's own echo.

+ */ + @EdtTest + public void aTombstoneDoesNotReleaseThePublisherWhileAReadIsOwed() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + // The tombstone arrives FROM the read, which is what makes this reachable: pollFinished() + // clears `polling` before handing the state to admit(), so the tombstone branch runs in a + // window where the publisher looks free while a coalesced read is still owed. A first + // version delivered the tombstone by hand while a fetch was blocked -- `polling` was + // still true, startPublisher() stopped at its own guard, and the test passed against the + // unfixed code. + final AppState tombstone = new AppState() + .setDeviceId("some-other-device") + .setSequence(41L) + .setTimestamp(System.currentTimeMillis()); + final java.util.concurrent.atomic.AtomicInteger reads = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.CountDownLatch inTombstoneRead = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch releaseTombstone = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch releaseSecond = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.atomic.AtomicInteger publishedWhileReading = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean reading = + new java.util.concurrent.atomic.AtomicBoolean(); + + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + if (reading.get()) { + publishedWhileReading.incrementAndGet(); + } + published.add(state); + } + + public AppState fetch() { + int n = reads.incrementAndGet(); + if (n == 1) { + // setRelay() polls immediately. Answering nothing here leaves the arrival + // below free to park -- returning the tombstone on this read admitted it + // FIRST, so the seq-40 arrival was refused as already seen and there was + // nothing to supersede. + return null; + } + if (n == 2) { + // Held so the test can ask for another read WHILE this one is in flight, + // which is what sets pollAgain -- and then answers with the tombstone, so it + // reaches admit() from pollFinished() with `polling` already cleared. + inTombstoneRead.countDown(); + try { + releaseTombstone.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return tombstone; + } + // The coalesced follow-up, held open so an overlapping POST is observable. + reading.set(true); + try { + releaseSecond.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + reading.set(false); + return null; + } + }); + + // An arrival parked, so the tombstone has something to supersede, and work owed to the + // relay so there is a POST to release. + Continuity.deliver(fromElsewhere("waiting on the user", 40L)); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), "nothing parked to supersede"); + // Owed to the relay, and HELD by the parked arrival rather than sent. + Continuity.checkpoint(); + + // The read that will answer with the tombstone. + Continuity.pollRelay(); + final boolean[] inRead = new boolean[1]; + awaitOffEdt(new Runnable() { + public void run() { + try { + inRead[0] = inTombstoneRead.await(5L, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + assertTrue(inRead[0], "the tombstone read never started"); + // Asked for WHILE that read is in flight, which is what sets pollAgain. + Continuity.pollRelay(); + releaseTombstone.countDown(); + + pause(600L); + flushSerialCalls(); + pause(400L); + flushSerialCalls(); + + assertTrue(reads.get() >= 2, + "the coalesced follow-up read never happened, so nothing could overlap: reads=" + + reads.get()); + assertEquals(0, publishedWhileReading.get(), + "the tombstone released a POST while a coalesced read was still outstanding, so " + + "the two ran together against a relay that holds one document"); + + releaseSecond.countDown(); + pause(400L); + flushSerialCalls(); + } + + /** + * A restored stack that cannot be shown leaves the previous one in place. + * + *

show() runs application code. If it throws, the stack had already been replaced, so the + * old form stayed on screen while getCurrent(), back() and the next checkpoint all described + * a stack the user never saw -- and a later navigation persisted a restoration that failed.

+ */ + @EdtTest + public void aRestoredStackThatCannotBeShownLeavesThePreviousOne() { + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String path) { + if (path.startsWith("/explodes")) { + return new Form(path) { + @Override + public void show() { + throw new IllegalStateException("this screen cannot be shown"); + } + }; + } + return new Form(path); + } + }); + try { + Navigation.navigate("/account/statement"); + flushSerialCalls(); + assertEquals(1, Navigation.getStack().size(), "the fixture stack was not established"); + + List restored = new ArrayList(); + restored.add("/explodes"); + try { + Navigation.restoreStack(restored); + fail("showing threw, so restoreStack must not report success"); + } catch (IllegalStateException expected) { + assertEquals("this screen cannot be shown", expected.getMessage()); + } + + assertEquals(1, Navigation.getStack().size(), + "the stack was replaced by a restoration that could not be shown, so back() " + + "and the next checkpoint describe screens the user never saw"); + assertEquals("/account/statement", Navigation.getStack().get(0).getPath(), + "the previous stack was not the one that survived"); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From ead25752d0656e4e339cb345c2e33d6c37d14fb8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:32:46 +0300 Subject: [PATCH 078/140] Continuity: an aborted restore takes the screen back, not just the history Third layer of the same logout, and the previous fix could not reach this one. restoreStack() has already SHOWN the rebuilt form by the time the lifecycle check runs -- the cancellation came from inside that showing -- and clearStack() deliberately leaves the current form alone, which is a comment I wrote myself. So undoing the rebuilt stack removed the signed-out account's breadcrumbs and left its screen in front of the user. The stack and the display are two different things, and cancelling a restore has to answer for both. What was on screen before the rebuild is captured and put back, inside Continuity rather than by teaching Navigation about the lifecycle, and with show() rather than showBack(): this is not the user going back, it is a restore that never happened being undone. Showing a form directly does not go through Navigation, so it records nothing and checkpoints nothing. The identity comparison takes the tree's //NOPMD marker. Identity is the question -- is a DIFFERENT form showing now -- and CompareObjectsWithEquals fails the build for any finding. Full module 6279/6279, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics at its 191 baseline, copyright and control-character gates clean. Proven by removing the screen restoration alone, which leaves the stack fix in place and still fails. Three background gate runs were killed mid-flight while preparing this, so the suite, verify and ports legs were each re-run in the foreground. An interrupted run is not a passing one, and the last one produced no output at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 21 +++++++++++++++++++ .../continuity/LocalContinuityTest.java | 10 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 5dd768133fe..f21ff01e65b 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -927,6 +927,11 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // cannot recognize its own work -- it arrives as a foreign device's state -- so it // restores it and republishes in turn, and the two bounce the same stack back and forth, // re-navigating the user on every poll. + // What is on screen BEFORE the rebuild, so an aborted restore can put it back. The stack + // and the display are two different things: clearStack() deliberately leaves the current + // form alone -- the caller decides where to go next -- so undoing the history of a + // cancelled restore left the signed-out account's SCREEN in front of the user. + com.codename1.ui.Form beforeRestore = Display.getInstance().getCurrent(); boolean shown; applyingRestore = true; try { @@ -962,6 +967,22 @@ private static boolean restore(final AppState state, boolean[] outFailed) { } finally { clearingStack = false; } + // And the SCREEN, which the stack does not speak for. restoreStack() has already + // shown the rebuilt form by the time control gets here -- the cancellation came from + // inside that showing -- so without this the user is left looking at the signed-out + // account's work with only its history removed. + // + // show() rather than showBack(): this is not the user navigating back, it is a + // restore that never happened being undone. Showing a form directly does not go + // through Navigation, so it records nothing and checkpoints nothing. + try { + com.codename1.ui.Form now = Display.getInstance().getCurrent(); + if (beforeRestore != null && beforeRestore != now) { //NOPMD CompareObjectsWithEquals + beforeRestore.show(); + } + } catch (Throwable t) { + Log.e(t); + } outFailed[0] = true; return false; } 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 index 4a16938f944..135ab95ca95 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3582,11 +3582,19 @@ public Form dispatch(String path) { Continuity.deliver(arrival); flushSerialCalls(); - assertNull(Continuity.getRestorableState(), + assertNull(Continuity.getRestorableState(), "the restore committed after the route logged out, so the signed-out " + "account's stack and checkpoint are back"); assertTrue(Navigation.getStack().isEmpty(), "the rebuilt stack survived the logout that happened while building it"); + // The SCREEN as well as the history. clearStack() deliberately leaves the current + // form alone, so undoing only the stack left the signed-out account's work in front + // of the user with nothing but its breadcrumbs removed. + Form current = Display.getInstance().getCurrent(); + assertNotNull(current, "no form is showing at all"); + assertFalse("/account/statement".equals(current.getTitle()), + "the signed-out account's restored screen is still displayed after the " + + "logout that cancelled the restore"); } finally { Navigation.setDispatcher(null); Navigation.clearStack(); From 98a4ad2ff13af8bf46d803175e86935171ee1c13 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:58:57 +0300 Subject: [PATCH 079/140] Continuity: Navigation's rollback takes the screen back, and a write past the iCloud quota is refused Navigation's rollback had the same stack-versus-screen gap I had just fixed one layer up in Continuity. Display.setCurrentForm() installs the new form and only THEN runs onShowCompleted and the Show listeners, so a listener that throws leaves the failed form current -- and restoring the list alone left Display.getCurrent() and Navigation.getCurrent() describing different screens. It now puts the displayed form back as well. The iOS store refuses a write that would pass the quota instead of reporting success for it. Past the maximum, NSUbiquitousKeyValueStore keeps the value locally and declines to upload it, so the readback says yes to a write that will never reach another device -- while SyncedStore.put documents the opposite: false when "a key count or a size past what it allows". A value the store keeps and never propagates is the one outcome an application cannot detect for itself, and it is exactly when it needs its own fallback. Apple's published maxima are 1 MB and 1024 keys. The measurement is an approximation and deliberately generous -- UTF-8 bytes of the keys and string values, which is what this store is for -- because Apple does not publish how it counts and the risk worth avoiding is refusing a write the platform would have taken. It fires past the documented maximum, not near it. Verified by construction rather than by test: the iOS native path cannot be exercised here. Syntax-checked against the real iOS SDK for arm64 -- six diagnostics, all pre-existing and identical on master -- and the check proven to reach the new block by injecting an error into it, which takes it to seven. Two smaller things found while there. My earlier edit had spliced a note into the middle of put()'s "Returns" section, leaving the contract running straight into unrelated prose; it is a section of its own now. And importing Log into Navigation to shorten one call would have made three PRE-EXISTING fully-qualified uses violate UnnecessaryFullyQualifiedName, which is on the forbidden list -- so the new call follows the file's existing convention instead of improving one line at the cost of failing the build. Full module 6279/6279, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics at its 191 baseline, copyright, control-character and native-signature gates clean. Every leg re-run in the foreground after three background runs were killed mid-flight. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/sync/SyncedStore.java | 5 ++- .../src/com/codename1/router/Navigation.java | 18 ++++++++++ Ports/iOSPort/nativeSources/IOSNative.m | 35 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java index 316b9dbd37b..7230d4ff6d6 100644 --- a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -108,7 +108,10 @@ public static boolean isSupported() { /// /// true when the store holds the value afterwards; false when there is no store, or the /// platform would not take it -- a key count or a size past what it allows - /// Not gated on isSupported(), and that is the THIRD layer this was wrong in. + /// + /// #### Not gated on isSupported() + /// + /// That was the THIRD layer this was wrong in. /// /// isSupported() asks whether this build has a store that follows the user between devices, /// which is the right question for an application deciding whether to offer the feature and diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index d7c8a830893..3faeee4048c 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -251,6 +251,12 @@ public static boolean restoreStack(List paths) { // region has to contain only the call being guarded. check-cast-semantics.sh refuses the // other shape, correctly, and caught this exact line. Form top = rebuilt.get(rebuilt.size() - 1).getForm(); + // What is DISPLAYED before the attempt, which is not the same thing as the stack. + // Display.setCurrentForm() installs the new form and only then runs onShowCompleted and + // the Show listeners, so a listener that throws leaves the failed form current -- and + // restoring the list alone left getCurrent() and Navigation.getCurrent() describing + // different screens. + Form displayed = Display.getInstance().getCurrent(); try { // show(), not showBack(): the user is arriving, not going back, and showBack would // run the reverse transition into a screen they have not seen yet. @@ -258,6 +264,18 @@ public static boolean restoreStack(List paths) { } catch (RuntimeException e) { stack.clear(); stack.addAll(previous); + // And the screen with it. show() rather than showBack(): the user is not going back, + // an attempt that failed is being undone. + try { + Form now = Display.getInstance().getCurrent(); + if (displayed != null && displayed != now) { //NOPMD CompareObjectsWithEquals + displayed.show(); + } + } catch (RuntimeException ignored) { + // The original failure is the one worth reporting; losing it to a second one + // while cleaning up would hide what actually went wrong. + com.codename1.io.Log.e(ignored); + } throw e; } stackChanged(); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 35cae8281ac..a50aeae9dfe 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20793,6 +20793,41 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la POOL_BEGIN(); NSString *k = toNSString(CN1_THREAD_STATE_PASS_ARG key); NSString *v = toNSString(CN1_THREAD_STATE_PASS_ARG value); + // The QUOTA first, because past it the store keeps the value locally and simply declines to + // upload it -- the readback below then says yes to a write that will never reach another + // device, and SyncedStore.put documents the opposite: false when "a key count or a size past + // what it allows". Apple's published maxima for NSUbiquitousKeyValueStore are 1 MB in total + // and 1024 keys. + // + // The measurement is an APPROXIMATION and is deliberately generous: UTF-8 bytes of the keys + // and of the string values, which is what this store is for. Apple does not publish how it + // counts, so the risk to avoid is refusing a write the platform would have taken -- the check + // only fires past the documented maximum, not near it. + NSDictionary *held = [store dictionaryRepresentation]; + NSUInteger bytes = 0; + NSUInteger count = 0; + for (NSString *existing in held) { + if ([existing isEqualToString:k]) { + // Replaced, not added: its current size does not count towards the new total. + continue; + } + count++; + bytes += [existing lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + id value = [held objectForKey:existing]; + if ([value isKindOfClass:[NSString class]]) { + bytes += [((NSString *)value) lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + } + } + count++; + bytes += [k lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + bytes += [v lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + if (count > 1024 || bytes > 1048576) { + // Refused rather than written. A value the store keeps and never propagates is the one + // outcome an application cannot detect for itself, and it is exactly when it needs its + // own fallback. + POOL_END(); + return JAVA_FALSE; + } [store setString:v forKey:k]; // Asked for, not waited on and NOT reported. The system syncs on its own schedule and this // only moves it along; its answer is about the STORE -- whether this build is entitled to one From 3a7f52bd3d9886c2fbae0de9f6ea878498cd5e3b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:29:41 +0300 Subject: [PATCH 080/140] Continuity: a navigation whose own show() ended the session does not checkpoint Navigation notifies continuity AFTER the route's form has been shown, and a show callback calling clear() on an expired login is the ordinary way to end a session. The notification then described a session that no longer existed, and checkpointing it captured whatever the provider still held for the account that had just signed out -- while clear() promises that nothing follows it. Ordinary navigation only started notifying at all when clearStack() stopped being silent, so this arrived with that fix. One notification is skipped, not a mode: the navigation that was underway when the session ended produces no checkpoint, and the next one is ordinary. That is also right on its own terms, since the next navigation after a logout is the application going to its login screen. The guard's ORDER is load-bearing and was wrong first time. clear() empties the route stack itself and that emptying notifies too, so with the new test placed first, clear()'s own notification consumed the flag and the outer navigation checkpointed exactly as before. clearingStack has to answer for clear()'s internal notification. And a remote route this device cannot store no longer enters the navigation stack. Decoding accepts remote routes unchecked on purpose -- another device's mistake must not throw during a read -- but an accepted route reached the live stack, and the next checkpoint read that stack back through the validating setter: one over-long route threw out of capture(), left the pending flag set, and every later navigation retried the same throw while nothing was persisted or published again. It is dropped before the stack, with the rest of the state restored, and said once. Two of my own test mistakes, both caught by controls rather than by luck. The dispatcher logged out on EVERY show, so the "next navigation is ordinary" control could never pass -- and a control that cannot pass is not a control. And that control asserted isCheckpointPending(), which the flush's own checkpoint() clears, so it read false either way; it now asserts what was WRITTEN. That observable has been the wrong one three times on this branch. Full module 6281/6281, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics at its 191 baseline, copyright and control-character gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 53 ++++++++- .../continuity/LocalContinuityTest.java | 110 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index f21ff01e65b..bbbbb7a2dbc 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -521,6 +521,25 @@ public static void routeStackChanged() { // account's payload after logout had removed it. return; } + if (endedDuringNavigation) { + // The session ended while this navigation was in flight -- a route's show() callback + // calling clear() on an expired login is the ordinary way. Navigation notifies AFTER + // that callback has run, so the notification describes a session that no longer + // exists, and checkpointing it captures whatever the provider still holds for the + // account that just signed out. clear() promises nothing follows it. + // + // One notification, not a mode: the navigation that was underway is skipped and the + // next one is ordinary. That is also the right outcome on its own terms, because the + // next navigation after a logout is the application going to its login screen. + // + // AFTER the clearingStack guard, which is not cosmetic. clear() empties the route + // stack itself, and that emptying notifies too -- so with this test first, clear()'s + // own notification consumed the flag and the outer navigation went on to checkpoint + // exactly as before. The guard has to let clear()'s internal notification be answered + // by its own check. + endedDuringNavigation = false; + return; + } dirty = true; if (!Display.isInitialized() || flushScheduled) { return; @@ -904,7 +923,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { failed = true; } } - List routes = state.getRoutes(); + List routes = usableRoutes(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 @@ -1186,6 +1205,9 @@ public void run() { /// in this process can recall that. What this guarantees is that nothing follows it. public static void clear() { lifecycle++; + // Navigation notifies after the application code that may have called this, so the next + // notification -- if one is already on its way -- belongs to the session being ended. + endedDuringNavigation = true; parked = null; dirty = false; // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft @@ -1537,6 +1559,9 @@ private static void clearContinuation() { /// True while clear() is emptying the route stack, so its notification is ignored. private static boolean clearingStack; + /// Set by clear() so the navigation it happened inside does not checkpoint afterwards. + private static boolean endedDuringNavigation; + /// The parked state the publication hold has already been explained for, so it is said once. private static AppState heldFor; @@ -2205,6 +2230,31 @@ static boolean isInstalledRelay(StateRelay r) { return r != null && r == relay; //NOPMD CompareObjectsWithEquals } + /// The routes of a remote state that this device can actually keep. + /// + /// fromJson accepts a remote document's routes UNCHECKED, deliberately: another device's + /// mistake must not become an exception here. But an accepted route still ends up in the live + /// navigation stack, and the next checkpoint reads that stack back through the validating + /// setter -- so one route past this device's stored-string limit threw out of capture(), left + /// the pending flag set, and every later navigation retried the same throw while nothing was + /// persisted or published again. + /// + /// Dropped here instead, before it can enter the stack. A route that can never be + /// checkpointed has no business becoming the user's history, and saying so once is better + /// than a capture that fails for ever without explaining itself. + private static List usableRoutes(List routes) { + List out = new ArrayList(); + for (String route : routes) { + if (route != null && !StateCodec.exceedsWritableLength(route)) { + out.add(route); + continue; + } + Log.p("Continuity: ignoring a route from another device that is longer than this " + + "device can store. The rest of the state is restored."); + } + return out; + } + /// Whether completing `state` also finishes whatever is parked. /// /// Not identity. A device can have two states in flight -- a continuation and a relay poll @@ -2652,6 +2702,7 @@ static void reset() { lifecycle = 0; heldFor = null; clearingStack = false; + endedDuringNavigation = false; } /// The store notification, as a constant rather than an anonymous class per callback. 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 index 135ab95ca95..52cfe7d659c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3912,6 +3912,116 @@ public void show() { } } + /** + * A navigation whose own show() ended the session does not checkpoint afterwards. + * + *

Navigation notifies continuity AFTER the route's form has been shown, and a show callback + * calling clear() on an expired login is the ordinary way to end a session. The notification + * then described a session that no longer existed, and checkpointing it captured whatever the + * provider still held for the account that had just signed out -- while clear() promises that + * nothing follows it.

+ */ + @EdtTest + public void aNavigationWhoseShowEndedTheSessionDoesNotCheckpoint() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("secret", "the previous account's work"); + Continuity.setStateProvider(provider); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String path) { + // Only the account screen discovers the expiry. An unconditional logout made the + // login navigation below end the session too, so the control could never pass -- + // and a control that cannot pass is not a control. + if (!"/account/statement".equals(path)) { + return new Form(path); + } + return new Form(path) { + @Override + public void show() { + super.show(); + // "This login has expired." + Continuity.clear(); + } + }; + } + }); + try { + Navigation.navigate("/account/statement"); + flushSerialCalls(); + + assertFalse(Continuity.isCheckpointPending(), + "the navigation that ended the session left a checkpoint owed, so the " + + "signed-out account's payload is written back after logout"); + assertNull(Continuity.getRestorableState(), + "a checkpoint was written after the logout that cancelled it"); + + // And the NEXT navigation is ordinary: one notification is skipped, not a mode. + // + // Asserted on what was WRITTEN, not on the pending flag: the flush performs the + // checkpoint and checkpoint() clears that flag, so it reads false either way. That + // observable has now been the wrong one three times on this branch. + Navigation.navigate("/login"); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), + "navigation stopped checkpointing altogether after a logout, so nothing is " + + "ever stored again for the account that signs in next"); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + + /** + * A remote route this device cannot store never enters the navigation stack. + * + *

Decoding accepts a remote document's routes unchecked, deliberately -- another device's + * mistake must not throw here. But an accepted route reaches the live stack, and the next + * checkpoint reads that stack back through the validating setter: one route past this + * device's limit threw out of capture(), left the pending flag set, and every later + * navigation retried the same throw while nothing was persisted or published again.

+ */ + @EdtTest + public void aRemoteRouteThisDeviceCannotStoreNeverEntersTheStack() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String path) { + return new Form(path); + } + }); + try { + StringBuilder huge = new StringBuilder("/"); + while (huge.length() <= 65535) { + huge.append('r'); + } + AppState arrival = fromElsewhere("from a device with a longer limit", 55L); + List routes = new ArrayList(); + routes.add(huge.toString()); + routes.add("/account/statement"); + // Unchecked, exactly as a relay document would arrive. + arrival.setRoutesUnchecked(routes); + + Continuity.deliver(arrival); + flushSerialCalls(); + + // The usable route still restored; the impossible one did not come with it. + for (int i = 0; i < Navigation.getStack().size(); i++) { + assertTrue(Navigation.getStack().get(i).getPath().length() < 65535, + "a route this device cannot store entered the navigation stack, so the " + + "next checkpoint throws out of capture() for ever"); + } + + // And a checkpoint still works, which is what the stack poisoning prevented. + Continuity.checkpoint(); + flushSerialCalls(); + assertFalse(Continuity.isCheckpointPending(), + "capture() could not complete, so nothing is persisted or published again"); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From b76b7a34afcb6289a8b6d7f7bfc9cd2ed05d6c15 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:59:45 +0300 Subject: [PATCH 081/140] Continuity: notify on the stack change itself, and refuse relay fields of the wrong type The one-shot flag added last commit was armed too broadly, and the finding is right about why: on the ordinary direct-logout path nothing consumes it, because clear()'s own clearStack() notification is answered by the clearingStack guard -- which I had just reordered to make it so. The flag then sat armed and the NEXT unrelated navigation lost its checkpoint, so the first screen after a login could go unsaved. It is deleted rather than scoped, because the ordering it was compensating for was the actual defect. Navigation now notifies continuity when the STACK CHANGES, before running the form's own code, instead of afterwards. A show callback that ends the session then reaches clear(), which clears the pending flag, and the flush queued a moment earlier finds nothing owed. No flag, no state, and it is the more honest place for the notification: the stack changed at stack.add(), not after the UI settled. navigate(), back() and popTo() all move. The removed flag is not missed: the test for the previous finding still passes, which is what shows the ordering is doing the work. fromJson also refuses a document whose known fields carry the wrong kind of value. Valid syntax is not a valid state: {"device":"other","seq":"10", "payload":[]} parses cleanly and fromMap ignores the array where a payload belongs, leaving routes and payload both empty -- an EMPTY state, which this framework reads as a TOMBSTONE. One wrong type therefore recorded the origin as having cleared its work, marked it durably, and released a queued publish over the server's document. A seq that is not a number is refused for the same reason: asLong would answer zero, which is a sequence every later state supersedes. Only PRESENT and KNOWN fields are checked. An absent one is a smaller or older document, and an unknown one belongs to a sender that knows something this build does not -- refusing that would stop a newer device talking to an older one, and the test asserts it still can. Full module 6282/6282, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics at its 191 baseline, copyright and control-character gates clean. Each fix proven by reverting it alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 26 -------- .../com/codename1/continuity/StateCodec.java | 66 ++++++++++++++++++- .../src/com/codename1/router/Navigation.java | 14 +++- .../continuity/LocalContinuityTest.java | 41 ++++++++++++ 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index bbbbb7a2dbc..779ae0815db 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -521,25 +521,6 @@ public static void routeStackChanged() { // account's payload after logout had removed it. return; } - if (endedDuringNavigation) { - // The session ended while this navigation was in flight -- a route's show() callback - // calling clear() on an expired login is the ordinary way. Navigation notifies AFTER - // that callback has run, so the notification describes a session that no longer - // exists, and checkpointing it captures whatever the provider still holds for the - // account that just signed out. clear() promises nothing follows it. - // - // One notification, not a mode: the navigation that was underway is skipped and the - // next one is ordinary. That is also the right outcome on its own terms, because the - // next navigation after a logout is the application going to its login screen. - // - // AFTER the clearingStack guard, which is not cosmetic. clear() empties the route - // stack itself, and that emptying notifies too -- so with this test first, clear()'s - // own notification consumed the flag and the outer navigation went on to checkpoint - // exactly as before. The guard has to let clear()'s internal notification be answered - // by its own check. - endedDuringNavigation = false; - return; - } dirty = true; if (!Display.isInitialized() || flushScheduled) { return; @@ -1205,9 +1186,6 @@ public void run() { /// in this process can recall that. What this guarantees is that nothing follows it. public static void clear() { lifecycle++; - // Navigation notifies after the application code that may have called this, so the next - // notification -- if one is already on its way -- belongs to the session being ended. - endedDuringNavigation = true; parked = null; dirty = false; // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft @@ -1559,9 +1537,6 @@ private static void clearContinuation() { /// True while clear() is emptying the route stack, so its notification is ignored. private static boolean clearingStack; - /// Set by clear() so the navigation it happened inside does not checkpoint afterwards. - private static boolean endedDuringNavigation; - /// The parked state the publication hold has already been explained for, so it is said once. private static AppState heldFor; @@ -2702,7 +2677,6 @@ static void reset() { lifecycle = 0; heldFor = null; clearingStack = false; - endedDuringNavigation = false; } /// The store notification, as a constant rather than an anonymous class per callback. diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 0a2c7cad05f..77cb0d814c6 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -23,6 +23,7 @@ package com.codename1.continuity; import com.codename1.io.JSONParser; +import com.codename1.io.Log; import com.codename1.io.JSONWriter; import java.io.IOException; @@ -227,7 +228,70 @@ public static AppState fromJson(String json) throws IOException { + "relay, because a truncated document is indistinguishable from one that " + "says the other device has nothing."); } - return fromMap(JSONParser.parseJSON(json)); + Map parsed = JSONParser.parseJSON(json); + requireKnownTypes(parsed); + return fromMap(parsed); + } + + /// Refuses a document whose known fields carry the wrong kind of value. + /// + /// Valid syntax is not a valid STATE. `{"device":"other","seq":"10","payload":[]}` parses + /// cleanly, and fromMap then ignores the array where a payload belongs -- leaving routes and + /// payload both empty, which is an EMPTY state, which the framework reads as a tombstone. So + /// a relay serving one wrong type has the origin recorded as having cleared its work, marked + /// durably, and a queued publish released over the server's document. The same shape with + /// valid routes restores and acknowledges a state whose payload was silently dropped. + /// + /// Only fields that are PRESENT are checked, and only ones this codec knows. An absent field + /// is an older or smaller document, which is legitimate; an unknown field belongs to a + /// sender that knows something this build does not, and ignoring it is how the format stays + /// extensible. What is refused is a known field that cannot mean what it says. + private static void requireKnownTypes(Map m) throws IOException { + if (m == null) { + return; + } + requireType(m, KEY_ROUTES, List.class, "an array of route strings"); + requireType(m, KEY_PAYLOAD, Map.class, "an object"); + requireType(m, KEY_ENCODING, String.class, "a string"); + requireType(m, KEY_DEVICE, String.class, "a string"); + requireType(m, KEY_TITLE, String.class, "a string"); + requireNumberLike(m, KEY_SEQUENCE); + requireNumberLike(m, KEY_TIMESTAMP); + } + + private static void requireType(Map m, String key, Class type, String what) + throws IOException { + Object value = m.get(key); + if (value == null || type.isInstance(value)) { + return; + } + throw new IOException("The continuity relay returned a document whose \"" + key + + "\" is not " + what + ". Treated as a failed read rather than as a state, " + + "because a field this codec cannot use is indistinguishable from one that is " + + "absent -- and an absent payload and routes make an empty state, which means " + + "the sending device cleared its work."); + } + + /// seq and ts, which this codec writes as strings and older senders may write as numbers. + private static void requireNumberLike(Map m, String key) throws IOException { + Object value = m.get(key); + if (value == null || value instanceof Number) { + return; + } + if (value instanceof String) { + try { + Long.parseLong(((String) value).trim()); + return; + } catch (NumberFormatException err) { + // Falls through to the refusal below: a string that is not a number cannot be a + // sequence, and asLong() would silently answer 0 -- which is a valid-looking + // sequence that every later state supersedes. + Log.e(err); + } + } + throw new IOException("The continuity relay returned a document whose \"" + key + + "\" is not a number. Read as zero it would be a sequence every later state " + + "supersedes, so it is refused instead."); } /// Whether `json` is ONE syntactically valid JSON object and nothing else. diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 3faeee4048c..6c46cf997f5 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -104,8 +104,14 @@ public static boolean navigate(String path) { return false; } stack.add(new NavigationEntry(path, f)); - f.show(); + // BEFORE show(), which runs application code. The stack has already changed here, and + // that is what continuity records -- so notifying now means a show callback that ends the + // session (an expired login discovered on screen) is answered by clear() clearing the + // pending flag, and the flush queued a moment ago then finds nothing owed. Notifying + // afterwards described a session the callback had already ended, and checkpointed the + // signed-out account's payload. stackChanged(); + f.show(); return true; } @@ -119,8 +125,9 @@ public static boolean back() { } stack.remove(stack.size() - 1); NavigationEntry now = stack.get(stack.size() - 1); - now.getForm().showBack(); + // Before showBack(), for the reason navigate() gives. stackChanged(); + now.getForm().showBack(); return true; } @@ -186,8 +193,9 @@ public static boolean popTo(NavigationEntry entry) { while (stack.size() > idx + 1) { stack.remove(stack.size() - 1); } - entry.getForm().showBack(); + // Before showBack(), for the reason navigate() gives. stackChanged(); + entry.getForm().showBack(); return true; } 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 index 52cfe7d659c..cee0190d6b7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4022,6 +4022,47 @@ public Form dispatch(String path) { } } + /** + * A relay field of the wrong type is a failed read, not an empty state. + * + *

Valid syntax is not a valid state. {@code {"device":"other","seq":"10","payload":[]}} + * parses cleanly and fromMap ignores the array where a payload belongs, leaving routes and + * payload both empty -- which is an EMPTY state, which the framework reads as a tombstone. One + * wrong type therefore records the origin as having cleared its work, marks it durably, and + * releases a queued publish over the server's document.

+ */ + @EdtTest + public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { + // The control first: a document this codec writes must still read. + assertNotNull(StateCodec.fromJson(StateCodec.toJson(fromElsewhere("real", 4L))), + "a document this codec itself wrote was refused"); + + String[] wrong = { + "{\"device\":\"other\",\"seq\":\"10\",\"payload\":[]}", + "{\"device\":\"other\",\"seq\":\"10\",\"routes\":{}}", + "{\"device\":42,\"seq\":\"10\"}", + "{\"device\":\"other\",\"seq\":\"not a number\"}", + "{\"device\":\"other\",\"seq\":\"10\",\"title\":[1,2]}", + }; + for (int i = 0; i < wrong.length; i++) { + try { + AppState s = StateCodec.fromJson(wrong[i]); + fail("a document with a wrong field type was accepted: " + wrong[i] + + (s != null && s.isEmpty() + ? " -- and as an EMPTY state, which is read as a tombstone" + : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + } + + // A field this codec does not know is NOT a failure: that is how the format stays + // extensible, and refusing it would break every sender that knows more than this build. + assertNotNull(StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"10\",\"somethingNewer\":{\"a\":1}}"), + "an unknown field was refused, so a newer sender cannot talk to this build"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From 41974ddf0e34e1999e540894600c955c70d5fa4c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:27:51 +0300 Subject: [PATCH 082/140] Continuity: guard both exits of the save callback, keep relay nulls visible, verify the marks removal The lifecycle check around StateProvider.saveState() sat on the normal-return path only. A provider that called clear() and THEN threw -- cleanup failing after it noticed an expired account -- took the catch and carried on, so its state was persisted and advertised for the account that had just signed out. The question is asked once, after the try/catch, where both exits reach it. fromJson keeps null fields visible now. The convenience parser drops a null-valued field before anything can look at it, so {"payload":null} arrived as an ABSENT payload -- and absent routes with an absent payload is an empty state, which this framework reads as a TOMBSTONE. The type checks added last round for payload:[] could not see it, because by then the key was gone. Parsed through an instance with nulls kept, and a known field that is present and null is refused: a sender that means "absent" leaves the key out, and the test asserts that still works. And the delivery-mark removal is blanked and checked, like the checkpoint cleanup beside it. An unverified delete next to a verified one was the inconsistency -- deleteStorageFile() returns void and the ports discard the answer they get, so if rememberSeen()'s write had also failed the previous account's marks stayed on disk and the next launch reloaded them. Worth being exact about that last one: it makes the failure RETRIED and REPORTED rather than silent, and it cannot guarantee removal when storage refuses both the write and the delete. Nothing can. That is the same limit the checkpoint cleanup already documents, and the existing marks test -- write refused, delete working -- still passes. Full module 6284/6284, SpotBugs 0 across core/ios/android/plugin, forbidden PMD 0, cast semantics at its 191 baseline, copyright and control-character gates clean. The first two proven by reverting each alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 28 +++++++--- .../com/codename1/continuity/StateCodec.java | 26 ++++++++- .../continuity/LocalContinuityTest.java | 56 +++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 779ae0815db..9dd8285d2f9 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -669,13 +669,6 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile Map payload = null; try { payload = p.saveState(); - if (lifecycle != lifecycleAtCapture) { - // Same rule on the way OUT. A provider that ends the session while being - // asked what to save must not then have that answer stored and published for - // the account it just signed out of. - sequenceFailed[0] = true; - return null; - } } 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 @@ -690,6 +683,15 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile Log.e(t); payloadFailed[0] = true; } + if (lifecycle != lifecycleAtCapture) { + // Asked AFTER the try/catch, so both exits answer it. The check used to sit on + // the normal-return path only, so a provider that ended the session and THEN + // threw -- cleanup failing after it noticed an expired account -- carried on + // here and had its state persisted and advertised for the account that had just + // signed out. + sequenceFailed[0] = true; + return null; + } if (payload != null) { // NOT caught. An unrepresentable value is a programming error with exactly one // correct moment to surface -- here, naming the key -- rather than as a payload @@ -1226,7 +1228,19 @@ public static void clear() { rememberSeen(); try { if (Display.isInitialized() && Storage.getInstance().exists(PREF_SEEN)) { + // Blanked and CHECKED before the delete, exactly as the checkpoint below is, and + // for the reason written there: deleteStorageFile() returns void and the ports + // discard the answer they get, so a refused deletion is invisible. An unverified + // delete beside a verified one was the inconsistency -- if rememberSeen()'s write + // failed too, the signed-out account's marks stayed on disk and the next launch + // reloaded them, suppressing the next account's states from the same origins. + boolean blanked = Storage.getInstance().writeObject(PREF_SEEN, ""); Storage.getInstance().deleteStorageFile(PREF_SEEN); + if (!blanked && Storage.getInstance().exists(PREF_SEEN)) { + Log.p("Continuity: the delivery marks could not be removed on logout, so the " + + "previous account's origins may go on suppressing states after a " + + "restart."); + } } } catch (Throwable t) { Log.e(t); diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 77cb0d814c6..9b2b3988f0b 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -228,7 +228,14 @@ public static AppState fromJson(String json) throws IOException { + "relay, because a truncated document is indistinguishable from one that " + "says the other device has nothing."); } - Map parsed = JSONParser.parseJSON(json); + // Parsed with NULLS KEPT. The convenience parser drops a null-valued field before + // anything can look at it, so {"payload":null} reached the checks below as an ABSENT + // payload -- and absent routes plus an absent payload is an empty state, which this + // framework reads as a tombstone. A field that is present and null has to stay + // distinguishable from one that was never sent. + JSONParser parser = new JSONParser(); + parser.setIncludeNullsInstance(true); + Map parsed = parser.parseJSON(new java.io.StringReader(json)); requireKnownTypes(parsed); return fromMap(parsed); } @@ -261,8 +268,18 @@ private static void requireKnownTypes(Map m) throws IOException private static void requireType(Map m, String key, Class type, String what) throws IOException { + if (!m.containsKey(key)) { + return; + } Object value = m.get(key); - if (value == null || type.isInstance(value)) { + if (value == null) { + throw new IOException("The continuity relay returned a document whose \"" + key + + "\" is null. A sender that means \"absent\" leaves the key out; a key that " + + "is present and empty is a document this codec cannot read, and reading it " + + "as absent would make it an empty state -- which means the sending device " + + "cleared its work."); + } + if (type.isInstance(value)) { return; } throw new IOException("The continuity relay returned a document whose \"" + key @@ -274,8 +291,11 @@ private static void requireType(Map m, String key, Class type /// seq and ts, which this codec writes as strings and older senders may write as numbers. private static void requireNumberLike(Map m, String key) throws IOException { + if (!m.containsKey(key)) { + return; + } Object value = m.get(key); - if (value == null || value instanceof Number) { + if (value instanceof Number) { return; } if (value instanceof String) { 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 index cee0190d6b7..a9b2eb33c73 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4063,6 +4063,62 @@ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { "an unknown field was refused, so a newer sender cannot talk to this build"); } + /** + * A provider that ends the session and then throws stops the capture too. + * + *

The lifecycle check sat on the normal-return path only, so a provider that called + * clear() and then failed -- cleanup breaking after it noticed an expired account -- carried + * on and had its state persisted and advertised for the account that had just signed out.

+ */ + @EdtTest + public void aProviderThatLogsOutAndThenThrowsStopsTheCapture() { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Continuity.clear(); + throw new IllegalStateException("cleanup failed after the logout"); + } + + public void restoreState(Map payload) { + } + }); + + Continuity.checkpoint(); + flushSerialCalls(); + + assertNull(Continuity.getRestorableState(), + "a checkpoint was written for the account the provider had just signed out of"); + } + + /** + * A relay field that is present and null is a failed read. + * + *

The convenience parser drops null-valued fields before anything can look at them, so + * {@code {"payload":null}} arrived as an ABSENT payload -- and absent routes with an absent + * payload is an empty state, which the framework reads as a tombstone. The type checks added + * for {@code payload:[]} could not see it.

+ */ + @EdtTest + public void aRelayFieldThatIsPresentAndNullIsAFailedRead() throws Exception { + String[] nulls = { + "{\"device\":\"other\",\"seq\":\"10\",\"payload\":null}", + "{\"device\":\"other\",\"seq\":\"10\",\"routes\":null}", + "{\"device\":\"other\",\"seq\":null}", + }; + for (int i = 0; i < nulls.length; i++) { + try { + AppState got = StateCodec.fromJson(nulls[i]); + fail("a null field was accepted: " + nulls[i] + + (got != null && got.isEmpty() ? " -- as an empty state, a tombstone" : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + } + + // Leaving the key OUT is how a sender says absent, and must still work. + assertNotNull(StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"}"), + "a document that simply omits a field was refused"); + } + /** Storage that refuses ONE name and passes everything else through. */ static class RefusingOneStorage extends Storage { private final Storage delegate; From dda20a56e3c06aed70ca085aa8a3c3504d713292 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:55:20 +0300 Subject: [PATCH 083/140] Validate route elements, roll back a failed navigation, isolate the device test Four findings, and three of them are the same shape as the ones before: a check that stopped one level short of where the bad value actually gets used. StateCodec checked that "routes" was a List and stopped there. {"routes":[1]} passed, the reader that builds the stack dropped the element it could not use, and what came out had no routes and no payload -- an empty AppState, which this framework reads as a TOMBSTONE. So a document with one bad element was consumed as an instruction to drop work, and marked durably, which means the sender's correction is then refused as already seen. Every element is checked now, and the fetch fails instead of arriving empty. Navigation now rolls the stack back when show() throws. This one is a direct consequence of a change earlier in this branch: the stack-change notification moved BEFORE show() so a listener sees the entry it is about to record. show() runs application code and can throw before the form is ever installed, and the entry left behind was then a screen the user never saw -- persisted by the checkpoint already queued, and restored after a process death. Same for back() and popTo(), which drop entries rather than add one. The flush reads the stack when it runs, so restoring it is what makes the queued checkpoint truthful; no separate cancellation is needed. The device conformance test wrote to a fixed synced-store key and never removed it, so a run interrupted between the write and the removal made the absent-value assertion fail on every later run, permanently. It now uses a key of its own and cleans up in a finally. It also asserted put() == isSupported(), which is a false equivalence I created myself when I split those two on iOS: isSupported() reports whether the entitlement probe established a store that follows the user, while put() writes to the local persistent store and succeeds even where that probe has not -- and a store at its quota refuses a write while remaining perfectly supported. The test now asserts what each API actually promises, on both branches. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 24 ++++ .../src/com/codename1/router/Navigation.java | 34 ++++- .../continuity/LocalContinuityTest.java | 61 +++++++++ .../com/codename1/router/NavigationTest.java | 128 ++++++++++++++++++ .../tests/ContinuityStateTest.java | 38 ++++-- 5 files changed, 273 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 9b2b3988f0b..80ffb7bc54b 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -258,6 +258,7 @@ private static void requireKnownTypes(Map m) throws IOException return; } requireType(m, KEY_ROUTES, List.class, "an array of route strings"); + requireRouteStrings(m); requireType(m, KEY_PAYLOAD, Map.class, "an object"); requireType(m, KEY_ENCODING, String.class, "a string"); requireType(m, KEY_DEVICE, String.class, "a string"); @@ -266,6 +267,29 @@ private static void requireKnownTypes(Map m) throws IOException requireNumberLike(m, KEY_TIMESTAMP); } + /// Every ELEMENT of the route array, not just the array. + /// + /// Checking the container alone left {"routes":[1]} passing: the list is a list, the loop + /// that reads it drops the element it cannot use, and what comes out has no routes and no + /// payload -- an empty state, which this framework reads as a tombstone. The same door as a + /// wrong payload type and a null field, one level further in. + private static void requireRouteStrings(Map m) throws IOException { + Object routes = m.get(KEY_ROUTES); + if (!(routes instanceof List)) { + return; + } + List list = (List) routes; + for (int i = 0; i < list.size(); i++) { + if (list.get(i) instanceof String) { + continue; + } + throw new IOException("The continuity relay returned a document whose route at index " + + i + " is not a string. Dropping it would leave a state with fewer routes " + + "than the sender meant, and dropping the only one would make it an empty " + + "state -- which means the sending device cleared its work."); + } + } + private static void requireType(Map m, String key, Class type, String what) throws IOException { if (!m.containsKey(key)) { diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 6c46cf997f5..5b16796932c 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -103,6 +103,7 @@ public static boolean navigate(String path) { if (f == null) { return false; } + List before = new ArrayList(stack); stack.add(new NavigationEntry(path, f)); // BEFORE show(), which runs application code. The stack has already changed here, and // that is what continuity records -- so notifying now means a show callback that ends the @@ -111,7 +112,18 @@ public static boolean navigate(String path) { // afterwards described a session the callback had already ended, and checkpointed the // signed-out account's payload. stackChanged(); - f.show(); + try { + f.show(); + } catch (RuntimeException e) { + // The entry goes back. show() can throw before the form is ever installed, and the + // entry left behind was a screen the user never saw -- persisted by the checkpoint + // this method has already queued, and restored after a process death. The flush reads + // the stack when it runs, so putting it back is what makes that checkpoint describe + // the truth. + stack.clear(); + stack.addAll(before); + throw e; + } return true; } @@ -123,11 +135,19 @@ public static boolean back() { if (stack.size() <= 1) { return false; } + List before = new ArrayList(stack); stack.remove(stack.size() - 1); NavigationEntry now = stack.get(stack.size() - 1); + Form back = now.getForm(); // Before showBack(), for the reason navigate() gives. stackChanged(); - now.getForm().showBack(); + try { + back.showBack(); + } catch (RuntimeException e) { + stack.clear(); + stack.addAll(before); + throw e; + } return true; } @@ -190,12 +210,20 @@ public static boolean popTo(NavigationEntry entry) { if (idx == stack.size() - 1) { return true; } + List before = new ArrayList(stack); while (stack.size() > idx + 1) { stack.remove(stack.size() - 1); } + Form target = entry.getForm(); // Before showBack(), for the reason navigate() gives. stackChanged(); - entry.getForm().showBack(); + try { + target.showBack(); + } catch (RuntimeException e) { + stack.clear(); + stack.addAll(before); + throw e; + } return true; } 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 index a9b2eb33c73..b88e2ea7b31 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2560,6 +2560,61 @@ public boolean stateReceived(AppState state) { + "checkpoint behind an arrival the application has finished with"); } + /** + * A route array whose elements are not strings fails the fetch instead of arriving empty. + * + *

This is the whole chain, not just the codec: {@code {"routes":[1]}} passed the outer + * List check, the reader dropped the element it could not use, and what came back was an + * AppState with no routes and no payload. That is precisely a tombstone -- the shape this + * framework reads as "the origin cleared its work" -- so a document that merely had one bad + * element was consumed as an instruction to drop work, and it was marked durably so the + * correction could never be re-read.

+ * + *

The observable here is that durable mark. A refused fetch is an IOException the poll + * reports, and nothing about the sender is remembered; an admitted tombstone always records + * one, which is what {@code aConsumedTombstoneIsMarkedDurably} pins down.

+ */ + @EdtTest + public void aFetchWithANonStringRouteIsNotConsumedAsATombstone() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + // What the OLD code produced from this document, spelled out so the harm is not taken on + // trust: an empty state, which isEmpty() -- and therefore admission -- reads as a + // tombstone. + AppState routeless = new AppState() + .setDeviceId("bad-element-sender") + .setSequence(10L) + .setTimestamp(System.currentTimeMillis()); + assertTrue(routeless.isEmpty(), + "an AppState with no routes and no payload is not empty here, so the tombstone " + + "consequence this test is about does not exist"); + + final String[] doc = { + "{\"device\":\"bad-element-sender\",\"seq\":\"10\",\"routes\":[1]}" + }; + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() throws java.io.IOException { + // Exactly what RestStateRelay does with the body it received. + return StateCodec.fromJson(doc[0]); + } + }); + // setRelay() polls on a background thread, so a flush alone proves nothing: the first + // version of this test asserted before the fetch had run and passed against the unfixed + // code. Wait for the poll, then drain what it queued. + pause(300L); + flushSerialCalls(); + + assertNull(Continuity.readSeenForTest().get("bad-element-sender"), + "a document with a non-string route was admitted and marked durably, so a " + + "malformed element was consumed as an instruction to drop work -- and " + + "the mark means the sender's correction is refused as already seen"); + } + /** * A consumed tombstone is marked durably. * @@ -4043,6 +4098,12 @@ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { "{\"device\":42,\"seq\":\"10\"}", "{\"device\":\"other\",\"seq\":\"not a number\"}", "{\"device\":\"other\",\"seq\":\"10\",\"title\":[1,2]}", + // The array is an array; its CONTENTS are the door. Checking the container alone let + // these through, and the reader that drops what it cannot use turned each of them + // into a state with fewer routes than the sender meant. + "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[1]}", + "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",null]}", + "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",{\"b\":1}]}", }; for (int i = 0; i < wrong.length; i++) { try { diff --git a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java index 875b03d0663..88fbca58a37 100644 --- a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java @@ -72,6 +72,134 @@ private int baseline() { return Navigation.getStack().size(); } + /** A Form that refuses to be shown, the way application code reached from show() -- a + * showListener, or an overridden onShowCompleted -- can. Forward navigation and back + * navigation are separated because a stack cannot be built out of forms that refuse both. */ + private static final class RefusingForm extends Form { + private final boolean refuseForward; + + RefusingForm(String title, boolean refuseForward) { + setTitle(title); + this.refuseForward = refuseForward; + } + + public void show() { + if (refuseForward) { + throw new IllegalStateException("this form refuses to be shown"); + } + super.show(); + } + + public void showBack() { + if (!refuseForward) { + throw new IllegalStateException("this form refuses to be shown"); + } + super.showBack(); + } + } + + /** A dispatcher that answers one path with a form that refuses, and every other path with an + * ordinary one. */ + private static final class RefusingDispatcher implements RouteDispatcher { + private final String refusing; + private final boolean refuseForward; + + RefusingDispatcher(String refusing, boolean refuseForward) { + this.refusing = refusing; + this.refuseForward = refuseForward; + } + + public Form dispatch(String url) { + if (refusing.equals(url)) { + return new RefusingForm(url, refuseForward); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + } + + /** + * A navigation whose show() throws leaves the stack as it was. + * + *

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

+ */ + @FormTest + void navigateRollsTheStackBackWhenShowThrows() { + Navigation.setDispatcher(new FakeDispatcher().route("/a")); + Navigation.navigate("/a"); + int before = baseline(); + NavigationEntry current = Navigation.getCurrent(); + + Navigation.setDispatcher(new RefusingDispatcher("/explodes", true)); + try { + Navigation.navigate("/explodes"); + fail("show() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller sees the failure. What must not survive it is the stack entry. + } + + assertEquals(before, baseline(), + "the entry for a screen that was never shown stayed on the stack, so a " + + "checkpoint persists it and a cold start restores a screen the user " + + "never reached"); + assertSame(current, Navigation.getCurrent(), + "the failed navigation is reported as the current entry"); + } + + /** + * A back whose showBack() throws puts the popped entry back. + */ + @FormTest + void backRestoresThePoppedEntryWhenShowThrows() { + Navigation.setDispatcher(new RefusingDispatcher("/refuses", false)); + Navigation.navigate("/refuses"); + Navigation.navigate("/top"); + int before = baseline(); + NavigationEntry top = Navigation.getCurrent(); + + try { + Navigation.back(); + fail("showBack() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // As above. + } + + assertEquals(before, baseline(), + "the entry was popped for a screen that never appeared, so the stack now " + + "describes a place the user is not"); + assertSame(top, Navigation.getCurrent(), "the failed back moved the current entry"); + } + + /** + * A popTo whose showBack() throws puts every popped entry back. + */ + @FormTest + void popToRestoresEveryPoppedEntryWhenShowThrows() { + Navigation.setDispatcher(new RefusingDispatcher("/refuses", false)); + Navigation.navigate("/refuses"); + NavigationEntry target = Navigation.getCurrent(); + Navigation.navigate("/mid"); + Navigation.navigate("/top"); + int before = baseline(); + NavigationEntry top = Navigation.getCurrent(); + + try { + Navigation.popTo(target); + fail("showBack() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // As above. + } + + assertEquals(before, baseline(), + "popTo dropped several entries for a screen that never appeared"); + assertSame(top, Navigation.getCurrent(), "the failed popTo moved the current entry"); + } + @FormTest void navigateReturnsFalseWithoutDispatcher() { Navigation.setDispatcher(null); diff --git a/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 index b41e5478932..ed9b55ba6eb 100644 --- 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 @@ -136,16 +136,36 @@ public void restoreState(Map payload) { // 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"); + // + // A key of THIS RUN's own, removed first and cleaned up in a finally. A fixed key left + // behind by a run that was interrupted between the write and the removal made the + // absent-value assertion fail on every later run, permanently -- and on iOS the value + // can also arrive from another device, which no amount of local cleanup prevents. + String key = "cn1ss.sortOrder." + System.currentTimeMillis(); + try { + SyncedStore.remove(key); + assertEqual("byName", SyncedStore.get(key, "byName"), + "an absent synced value answers with the default"); + + // NOT asserted equal to isSupported(). They answer different questions on iOS by + // design: isSupported() reports whether the entitlement probe has established a + // store that follows the user, while put() writes to the local persistent store + // and succeeds even when that probe has not -- and a store at its quota refuses a + // write while remaining perfectly supported. Tying them together made this fail + // for a device that was merely offline, or whose store was full, with both APIs + // keeping their documented contracts. + boolean wrote = SyncedStore.put(key, "byDate"); + if (wrote) { + assertEqual("byDate", SyncedStore.get(key, "byName"), + "a value the store accepted reads back"); + } else { + assertEqual("byName", SyncedStore.get(key, "byName"), + "a write the store refused left nothing behind"); + } + assertBool(SyncedStore.keys() != null, "the key list is never null"); + } finally { + SyncedStore.remove(key); } - 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. From a29c14b92bda04003fe7a9b5a74303ffb7149b07 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:27:05 +0300 Subject: [PATCH 084/140] Bind a relay call to its session, bound the sequence, count every KVS value Three findings, and each one is a check that answered a narrower question than the one it was placed to answer. The relay identity check asked "is this relay installed". setRelay() swaps the object, so a REPLACED relay was caught -- but clear() deliberately leaves the same relay in place, because the same endpoint usually serves the next account. A logout was therefore invisible at that line: a worker whose EDT preflight passed a moment before clear() ran found its relay still installed and sent the previous account's state anyway, which is exactly what clear() promises not to allow. With cookie or client-certificate authentication there is not even a token for getToken() to have stopped returning. So the session is asked as well, at the same point, and asked on the event thread because relaySession belongs to it. The session travels to that point in a ThreadLocal a worker binds around its own relay call -- a thread local rather than a static because two workers can be in flight at once: startPoll() guards against a second READ, not against a write, so a poll can begin while a publish is on the wire. A relay the application drives itself has no session bound and gets the identity answer it always got; refusing those would break a legitimate direct call for a session it was never part of. StateCodec required the sequence to be a Number and stopped there. JSONParser answers a bare 1e100 with a Double, asLong() clamps it to Long.MAX_VALUE, and once that is an origin's durable high-water mark every ordinary sequence it sends afterwards is refused as already seen -- for the life of the installation. A fractional value is the same harm in miniature: 5.7 becomes 5, so the sender's own 5 is then indistinguishable from it. Integral, finite and in range now, with a test that an ordinary numeric seq still goes through, because a guard that refused every number would have passed the first half of this and silently dropped every sender that writes seq as a number. The iCloud quota preflight counted string values and skipped everything else, which counts an NSData, an array or a dictionary as ZERO. The store is not only ours -- an app that used NSUbiquitousKeyValueStore before adopting this API, or a container shared with an extension, holds values of every plist kind -- so the check passed on a store already over its limit, which is precisely the case it exists to catch: the write is kept locally, the readback says yes, and it never propagates. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 65 +++++++++ .../codename1/continuity/RestStateRelay.java | 13 +- .../com/codename1/continuity/StateCodec.java | 16 +++ Ports/iOSPort/nativeSources/IOSNative.m | 44 +++++- .../continuity/LocalContinuityTest.java | 128 +++++++++++++++++- 5 files changed, 254 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 9dd8285d2f9..6533674b479 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -221,6 +221,15 @@ public final class Continuity { /// delivering the previous account's state into the next account's screen. private static int relaySession; + /// The relay session a framework worker is running for, bound for the length of its call + /// into the relay and unbound afterwards. Null on the event thread and on any thread the + /// application drives itself, which is how mayRelaySend() tells the two apart. + /// + /// A thread local rather than a static, because two workers can be in flight at once: a poll + /// starts while a publish is on the wire -- startPoll() guards against a second READ, not + /// against a write -- so one field would answer for whichever worker wrote it last. + private static final ThreadLocal RELAY_CALL_SESSION = new ThreadLocal(); + private Continuity() { } @@ -1097,6 +1106,7 @@ public void run() { // a local and the answer goes back through the event queue. AppState fetched = null; boolean failed = false; + RELAY_CALL_SESSION.set(Integer.valueOf(session)); try { fetched = r.fetch(); } catch (Throwable t) { @@ -1107,6 +1117,8 @@ public void run() { // that this device had never managed to read. Log.e(t); failed = true; + } finally { + RELAY_CALL_SESSION.remove(); } final AppState result = fetched; final boolean fetchFailed = failed; @@ -1708,11 +1720,14 @@ public void run() { // Off the EDT because publish() blocks, and touching NOTHING: the relay and the // state came in as locals and the outcome goes back through the event queue. boolean sent = true; + RELAY_CALL_SESSION.set(Integer.valueOf(session)); try { r.publish(next); } catch (Throwable t) { Log.e(t); sent = false; + } finally { + RELAY_CALL_SESSION.remove(); } final boolean ok = sent; Display.getInstance().callSerially(new Runnable() { @@ -2219,6 +2234,56 @@ static boolean isInstalledRelay(StateRelay r) { return r != null && r == relay; //NOPMD CompareObjectsWithEquals } + /// Whether `r` may send RIGHT NOW: it is the installed relay, and the session its caller + /// belongs to has not ended. + /// + /// The identity half alone answered only one of the two questions. `setRelay()` swaps the + /// object, so a replaced relay is caught -- but `clear()` deliberately leaves the same relay + /// INSTALLED, because the same endpoint usually serves the next account. Its logout was + /// therefore invisible here: a worker whose preflight passed a moment before `clear()` ran + /// found its relay still installed and sent the previous account's state anyway, which is + /// exactly what `clear()` promises not to allow. Cookie or client-certificate authentication + /// makes that concrete -- there is no token for `getToken()` to have stopped returning. + /// + /// So the session is asked as well, and asked on the EVENT THREAD, because `relaySession` + /// belongs to it. Blocking a worker on the EDT is the safe direction: the EDT never waits on + /// a worker. + /// + /// Only a framework worker has a session bound to it. A relay the application drives itself + /// -- `RestStateRelay` is a public class and usable on its own -- has none, and gets the + /// identity answer it always got; refusing those would break a legitimate direct call for a + /// session it was never part of. + /// + /// What this still cannot close is the instant between this answer and the request that + /// follows it. That is instructions on one thread rather than an unbounded wait on a queue, + /// which is the whole of what a check placed here can buy. + static boolean mayRelaySend(final StateRelay r) { + if (r == null) { + return false; + } + Integer bound = RELAY_CALL_SESSION.get(); + if (bound == null) { + return isInstalledRelay(r); + } + final int session = bound.intValue(); + final boolean[] live = new boolean[1]; + try { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + live[0] = r == relay //NOPMD CompareObjectsWithEquals + && session == relaySession; + } + }); + } catch (Throwable t) { + // Refused, not allowed. Whatever stopped the event thread from answering, sending + // under an unknown session is the outcome this method exists to prevent. + Log.e(t); + return false; + } + return live[0]; + } + /// The routes of a remote state that this device can actually keep. /// /// fromJson accepts a remote document's routes UNCHECKED, deliberately: another device's diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java index 4d9881f5dac..4ce05d293dc 100644 --- a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -164,11 +164,16 @@ public AppState fetch() throws IOException { /// the gap that check cannot cover -- it runs on the event thread, and the worker is not it. /// Throwing rather than skipping quietly, so the framework records the publish as failed and /// keeps owing it, and the state is republished once a relay is installed again. + /// + /// It asks whether this relay may SEND, not merely whether it is installed. A logout keeps + /// the same relay object in place on purpose, so identity alone said yes to a worker whose + /// account had signed out between its preflight and this line. private RequestBuilder auth(RequestBuilder b) throws IOException { - if (!Continuity.isInstalledRelay(this)) { - throw new IOException("This relay is no longer installed -- Continuity.clear() or " - + "setRelay() replaced it. Refusing the request rather than sending one " - + "account's state under another account's credentials."); + if (!Continuity.mayRelaySend(this)) { + throw new IOException("This relay may not send: Continuity.setRelay() replaced it, " + + "or Continuity.clear() ended the session this request belongs to. Refusing " + + "the request rather than sending one account's state under another " + + "account's credentials."); } // SILENT, because these are housekeeping requests the user never asked for. A request // builder sets failSilently only when an error-code handler is registered, and without it diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 80ffb7bc54b..887cdb6f569 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -320,6 +320,22 @@ private static void requireNumberLike(Map m, String key) throws } Object value = m.get(key); if (value instanceof Number) { + // NOT just "a number". JSONParser answers a bare 1e100 with a Double, and asLong() + // then converts it to Long.MAX_VALUE -- so one such document raises this origin's + // durable high-water mark to the largest value there is, and every ordinary sequence + // it sends afterwards is refused as already seen, for the life of the installation. + // A fractional value is refused for the same reason in miniature: 5.7 becomes 5, and + // the sender's 5 is then indistinguishable from it. + double d = ((Number) value).doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d) + || d != Math.floor(d) + || d < (double) Long.MIN_VALUE || d > (double) Long.MAX_VALUE) { + throw new IOException("The continuity relay returned a document whose \"" + key + + "\" is " + value + ", which is not a whole number this device can " + + "hold. Accepting it would clamp the value to the largest sequence " + + "there is and refuse every later state from that device as already " + + "seen."); + } return; } if (value instanceof String) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index a50aeae9dfe..6da84f3329e 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20784,6 +20784,39 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(C return (resolved != nil && cn1ContinuitySyncEntitled) ? JAVA_TRUE : JAVA_FALSE; } +// The size of one value already in the store, whatever KIND of value it is. +// +// This class only ever writes strings, so the quota check used to count strings and skip +// everything else -- which counts an NSData, an array or a dictionary as ZERO. The store is not +// only ours: an application that used NSUbiquitousKeyValueStore before adopting this API, or a +// container shared with an app extension, holds values of every plist kind. Skipping them made +// the check pass on a store already over its limit, which is precisely the case it exists to +// catch -- the write is kept locally, the readback says yes, and it never propagates. +static NSUInteger cn1ContinuityValueBytes(id value) { + if (value == nil) { + return 0; + } + if ([value isKindOfClass:[NSString class]]) { + return [((NSString *)value) lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + } + if ([value isKindOfClass:[NSData class]]) { + return [((NSData *)value) length]; + } + if ([value isKindOfClass:[NSNumber class]] || [value isKindOfClass:[NSDate class]]) { + // A scalar, whose encoded size is a handful of bytes whatever it holds. + return 16; + } + // An array or a dictionary. Serializing is the only way to ask how big a nested plist is, + // and the alternative this replaces was to call it nothing. Wrapped in an array because a + // bare root is not a property list for every format. + NSData *encoded = [NSPropertyListSerialization + dataWithPropertyList:[NSArray arrayWithObject:value] + format:NSPropertyListBinaryFormat_v1_0 + options:0 + error:NULL]; + return encoded != nil ? [encoded length] : 0; +} + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key, JAVA_OBJECT value) { NSUbiquitousKeyValueStore *store = cn1ContinuityStore(); if (store == nil || key == JAVA_NULL || value == JAVA_NULL) { @@ -20800,9 +20833,9 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la // and 1024 keys. // // The measurement is an APPROXIMATION and is deliberately generous: UTF-8 bytes of the keys - // and of the string values, which is what this store is for. Apple does not publish how it - // counts, so the risk to avoid is refusing a write the platform would have taken -- the check - // only fires past the documented maximum, not near it. + // plus the size of every value the store already holds, of whatever plist kind. Apple does + // not publish how it counts, so the risk to avoid is refusing a write the platform would + // have taken -- the check only fires past the documented maximum, not near it. NSDictionary *held = [store dictionaryRepresentation]; NSUInteger bytes = 0; NSUInteger count = 0; @@ -20813,10 +20846,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la } count++; bytes += [existing lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; - id value = [held objectForKey:existing]; - if ([value isKindOfClass:[NSString class]]) { - bytes += [((NSString *)value) lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; - } + bytes += cn1ContinuityValueBytes([held objectForKey:existing]); } count++; bytes += [k lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; 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 index b88e2ea7b31..5c0171a8842 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2420,7 +2420,7 @@ public AppState fetch() { relay.publish(new AppState().setDeviceId("previous-account").setSequence(1L)); fail("a relay that setRelay() replaced must not send anything"); } catch (java.io.IOException expected) { - assertTrue(expected.getMessage().contains("no longer installed"), + assertTrue(expected.getMessage().contains("may not send"), expected.getMessage()); } assertFalse(tokenRead[0], @@ -2560,6 +2560,122 @@ public boolean stateReceived(AppState state) { + "checkpoint behind an arrival the application has finished with"); } + /** + * A logout refuses a worker that is already inside the relay, even though the relay stays + * installed. + * + *

This is the half the identity check could not see. {@code setRelay()} swaps the object, + * so a replaced relay was caught -- but {@code clear()} deliberately leaves the SAME relay in + * place, because the same endpoint usually serves the next account. A worker whose preflight + * passed a moment before the logout therefore found its relay still installed and sent the + * previous account's state anyway. With cookie or client-certificate authentication there is + * not even a token for getToken() to have stopped returning.

+ * + *

Asked at the point {@code RestStateRelay.auth()} asks it, on the worker, inside the + * relay call -- which is the only place the window exists. The first answer is asserted too: + * a guard that refused every worker would pass the second half of this while breaking every + * ordinary publish.

+ */ + @EdtTest + public void aLogoutRefusesAWorkerAlreadyInsideTheRelay() { + final java.util.concurrent.atomic.AtomicInteger asked = + new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean beforeLogout = + new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicBoolean afterLogout = + new java.util.concurrent.atomic.AtomicBoolean(true); + final java.util.concurrent.CountDownLatch inPublish = + new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch loggedOut = + new java.util.concurrent.CountDownLatch(1); + final StateRelay[] self = new StateRelay[1]; + + StateRelay relay = new StateRelay() { + public void publish(AppState state) { + // Exactly what RestStateRelay.auth() does, in the same place: on the worker, + // inside the relay call, immediately before the credentials would be read. + beforeLogout.set(Continuity.mayRelaySend(self[0])); + asked.incrementAndGet(); + inPublish.countDown(); + try { + loggedOut.await(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + afterLogout.set(Continuity.mayRelaySend(self[0])); + asked.incrementAndGet(); + } + + public AppState fetch() { + return null; + } + }; + self[0] = relay; + + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Continuity.setRelay(relay); + Continuity.checkpoint(); + + // Pumped rather than blocked: the worker calls back onto the event thread to read the + // session, and this test IS the event thread. Blocking here would deadlock the very + // mechanism under test. + for (int i = 0; i < 40 && inPublish.getCount() > 0; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(0L, inPublish.getCount(), + "the publish worker never reached the relay, so this test asserts nothing"); + + Continuity.clear(); + loggedOut.countDown(); + for (int i = 0; i < 40 && asked.get() < 2; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(2, asked.get(), "the worker never asked again after the logout"); + + assertTrue(beforeLogout.get(), + "an ordinary worker was refused before any logout, which would stop every " + + "publish this framework makes"); + assertFalse(afterLogout.get(), + "clear() left the relay installed, so the worker was told it could still send -- " + + "and the previous account's state goes out after the logout that " + + "promised nothing would"); + } + + /** + * A sequence past the range of a long is a failed read, and an ordinary one still is not. + * + *

The pair matters: a guard that refused every numeric sequence would pass the first half + * of this and silently drop every sender that writes seq as a number rather than a string, + * which this codec has always accepted and still must.

+ */ + @EdtTest + public void anOutOfRangeSequenceIsRefusedAndAnOrdinaryOneIsNot() throws Exception { + AppState fine = StateCodec.fromJson("{\"device\":\"other\",\"seq\":10,\"ts\":99}"); + assertNotNull(fine, "a sender writing seq as a plain number was refused"); + assertEquals(10L, fine.getSequence(), "the numeric sequence did not survive"); + assertEquals(99L, fine.getTimestamp(), "the numeric timestamp did not survive"); + + // The largest sequence a long holds is itself in range and must go through: the guard is + // about values OUTSIDE the type, not about large ones. + AppState edge = StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"" + Long.MAX_VALUE + "\"}"); + assertEquals(Long.MAX_VALUE, edge.getSequence(), + "a sequence written as the largest long there is was not preserved"); + + try { + StateCodec.fromJson("{\"device\":\"other\",\"seq\":1e100}"); + fail("1e100 was accepted as a sequence -- clamped to Long.MAX_VALUE, it becomes this " + + "origin's durable high-water mark and refuses everything it sends later"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + } + /** * A route array whose elements are not strings fails the fetch instead of arriving empty. * @@ -4104,6 +4220,16 @@ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[1]}", "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",null]}", "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",{\"b\":1}]}", + // A sequence that is a NUMBER but not one this device can hold. asLong() clamps + // 1e100 to Long.MAX_VALUE, and once that is the durable high-water mark for this + // origin every ordinary sequence it sends afterwards is refused as already seen -- + // for the life of the installation. + "{\"device\":\"other\",\"seq\":1e100}", + "{\"device\":\"other\",\"seq\":-1e100}", + "{\"device\":\"other\",\"seq\":10,\"ts\":1e300}", + // Fractional, which is the same harm in miniature: 5.7 becomes 5, so the sender's + // own 5 is then indistinguishable from it. + "{\"device\":\"other\",\"seq\":5.7}", }; for (int i = 0; i < wrong.length; i++) { try { From a17fe2a0c50d48aeed8dde5a358ed5e3c5b8bfb1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:50:07 +0300 Subject: [PATCH 085/140] Do not resurrect a cleared stack, close the 2^63 alias, keep an unreadable boolean Three findings. The first is a bug I introduced one commit ago, and it is the more serious of the two halves of that change. The rollback I added for a navigation whose show() throws restored the stack unconditionally. But show() runs application code, and that code can navigate: the case that matters is a show listener discovering the session has expired, calling Continuity.clear() -- which empties this stack on purpose -- and then throwing on the way out. The rollback handed the signed-out account's forms straight back, reachable through getStack() and back() and persisted by the next checkpoint. A rollback meant to stop a screen the user never saw from being restored was undoing a logout instead. It now applies only when the stack is still exactly what the method left it as, compared by entry identity. That covers a show listener that navigated somewhere of its own just as well as a logout: anything that ran later and changed the stack deliberately wins. The sequence range guard from the same commit was one value short. 2^63 is the SAME double as (double) Long.MAX_VALUE -- that constant is not representable and rounds up -- so "greater than Long.MAX_VALUE" compares equal to a sender's 9223372036854775808 and let it through, to be clamped straight back to Long.MAX_VALUE by the conversion. That is exactly the durable high-water mark poisoning the guard was added to stop, one value past where it was looking. The bound is written against MIN_VALUE now, which is the only one of the two a double holds exactly, and is >= rather than >. It refuses a sender writing Long.MAX_VALUE itself as a bare number, which is unavoidable -- no double distinguishes it from the value one past the range -- and is why this codec writes seq as a string, where it parses exactly. Exact integer types skip the double entirely, because routing a Long through one would reject a perfectly good value near the top of the range, which is precisely the region a double cannot represent. JSONParser answers with Long when useLongs is on, and any code in the process can turn that on. Boolean.valueOf answers false for every string that is not "true", so a tagged payload of "b:unknown" arrived as a confident false: application data changed in transit, restored, and acknowledged, with nothing said. Every other tag here already preserves a body it cannot parse -- the number tags through their catch -- and this one now falls through to the same answer. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 36 +++++++++++++++- .../src/com/codename1/router/Navigation.java | 35 ++++++++++++--- .../continuity/LocalContinuityTest.java | 43 +++++++++++++++++++ .../com/codename1/router/NavigationTest.java | 41 ++++++++++++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 887cdb6f569..318674a5a1f 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -326,10 +326,31 @@ private static void requireNumberLike(Map m, String key) throws // it sends afterwards is refused as already seen, for the life of the installation. // A fractional value is refused for the same reason in miniature: 5.7 becomes 5, and // the sender's 5 is then indistinguishable from it. + if (value instanceof Long || value instanceof Integer + || value instanceof Short || value instanceof Byte) { + // Already a whole number inside the range, by its own type. Routing these + // through a double would REJECT a perfectly good Long near the top of the + // range, because that is precisely the region a double cannot represent. + // JSONParser answers with Long rather than Double when useLongs is on, which + // any code in the process can turn on through the static setter. + return; + } double d = ((Number) value).doubleValue(); + // The upper bound is >= and is written against MIN_VALUE, which is the only one of + // the two a double holds exactly. Long.MAX_VALUE is not representable: (double) + // Long.MAX_VALUE rounds UP to 2^63, so a sender's 9223372036854775808 -- one past + // the range -- compares equal to it and a "> (double) Long.MAX_VALUE" test let it + // through, to be clamped back to Long.MAX_VALUE by the conversion. -(double) + // Long.MIN_VALUE is exactly 2^63, and a double is convertible to a long precisely + // when it is at least -2^63 and strictly below 2^63. + // + // This does refuse a sender that writes Long.MAX_VALUE itself as a bare NUMBER, + // and there is no way not to: no double distinguishes it from the value one past + // the range. This codec writes seq as a STRING for exactly that reason, and the + // string path parses it exactly. if (Double.isNaN(d) || Double.isInfinite(d) || d != Math.floor(d) - || d < (double) Long.MIN_VALUE || d > (double) Long.MAX_VALUE) { + || d < (double) Long.MIN_VALUE || d >= -((double) Long.MIN_VALUE)) { throw new IOException("The continuity relay returned a document whose \"" + key + "\" is " + value + ", which is not a whole number this device can " + "hold. Accepting it would clamp the value to the largest sequence " @@ -738,7 +759,18 @@ private static Object decode(Object value) { return Double.valueOf(Double.parseDouble(body)); } if (tag == 'b') { - return Boolean.valueOf(body); + // The EXACT bodies the encoder writes, and nothing else. Boolean.valueOf answers + // false for every string that is not "true", so "b:unknown" arrived as a + // confident false: application data changed in transit, restored, and + // acknowledged, with nothing said. Every other tag here already preserves a body + // it cannot parse -- the number tags do it through the catch below -- and this + // one now falls through to the same answer. + if ("true".equals(body)) { + return Boolean.TRUE; + } + if ("false".equals(body)) { + return Boolean.FALSE; + } } } catch (NumberFormatException malformed) { // A tag whose body will not parse came from somewhere this build does not control. diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 5b16796932c..1a7d2c4e4b7 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -112,6 +112,7 @@ public static boolean navigate(String path) { // afterwards described a session the callback had already ended, and checkpointed the // signed-out account's payload. stackChanged(); + List expected = new ArrayList(stack); try { f.show(); } catch (RuntimeException e) { @@ -120,8 +121,7 @@ public static boolean navigate(String path) { // this method has already queued, and restored after a process death. The flush reads // the stack when it runs, so putting it back is what makes that checkpoint describe // the truth. - stack.clear(); - stack.addAll(before); + rollBack(before, expected); throw e; } return true; @@ -141,11 +141,11 @@ public static boolean back() { Form back = now.getForm(); // Before showBack(), for the reason navigate() gives. stackChanged(); + List expected = new ArrayList(stack); try { back.showBack(); } catch (RuntimeException e) { - stack.clear(); - stack.addAll(before); + rollBack(before, expected); throw e; } return true; @@ -163,6 +163,29 @@ public static List getStack() { return Collections.unmodifiableList(new ArrayList(stack)); } + /// Undoes a navigation whose show() threw -- but only if nothing else touched the stack + /// while it ran. + /// + /// `show()` runs application code, and that code can navigate. The case that matters is + /// `Continuity.clear()`: a show listener discovers the session has expired, logs out -- + /// which empties this stack on purpose -- and then throws on the way out. Restoring + /// unconditionally handed the signed-out account's forms straight back, reachable through + /// `getStack()` and `back()` and persisted by the next checkpoint. That is the one thing the + /// logout existed to prevent, undone by the rollback meant to help it. + /// + /// So the rollback applies only when the stack is still exactly what this method left it as. + /// Anything else -- a logout, or a show listener that navigated somewhere of its own -- is a + /// deliberate change by code that ran later, and it wins. `NavigationEntry` does not override + /// equals, so comparing the lists compares entry IDENTITY, which is what makes "still exactly + /// what I left" mean what it says. + private static void rollBack(List before, List expected) { + if (!expected.equals(stack)) { + return; + } + stack.clear(); + stack.addAll(before); + } + /// Forgets the navigation history, leaving nothing to go back to. /// /// For a logout, which is the case that needs it: `Continuity.clear()` calls this, because a @@ -217,11 +240,11 @@ public static boolean popTo(NavigationEntry entry) { Form target = entry.getForm(); // Before showBack(), for the reason navigate() gives. stackChanged(); + List expected = new ArrayList(stack); try { target.showBack(); } catch (RuntimeException e) { - stack.clear(); - stack.addAll(before); + rollBack(before, expected); throw e; } return true; 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 index 5c0171a8842..8eda43a9f94 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -2560,6 +2560,34 @@ public boolean stateReceived(AppState state) { + "checkpoint behind an arrival the application has finished with"); } + /** + * A tagged boolean the encoder never wrote keeps its text instead of becoming false. + * + *

Boolean.valueOf answers false for every string that is not "true", so "b:unknown" + * arrived as a confident false -- application data changed in transit, restored, and + * acknowledged, with nothing said. Every other tag in this codec already preserves a body it + * cannot parse; this one did not.

+ */ + @EdtTest + public void aTaggedBooleanThatWillNotParseIsNotSilentlyFalse() throws Exception { + AppState real = StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"3\",\"enc\":\"1\"," + + "\"payload\":{\"on\":\"b:true\",\"off\":\"b:false\"}}"); + assertEquals(Boolean.TRUE, real.getPayload().get("on"), + "the encoder's own true did not survive the round trip"); + assertEquals(Boolean.FALSE, real.getPayload().get("off"), + "the encoder's own false did not survive the round trip"); + + AppState odd = StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"3\",\"enc\":\"1\"," + + "\"payload\":{\"enabled\":\"b:unknown\"}}"); + Object kept = odd.getPayload().get("enabled"); + assertEquals("b:unknown", kept, + "a boolean body this codec cannot read became " + kept + " instead of keeping " + + "its text, so the application is handed a value the sender never sent " + + "-- and the state is acknowledged, so the sender never learns of it"); + } + /** * A logout refuses a worker that is already inside the relay, even though the relay stays * installed. @@ -2667,6 +2695,16 @@ public void anOutOfRangeSequenceIsRefusedAndAnOrdinaryOneIsNot() throws Exceptio assertEquals(Long.MAX_VALUE, edge.getSequence(), "a sequence written as the largest long there is was not preserved"); + try { + StateCodec.fromJson("{\"device\":\"other\",\"seq\":9223372036854775808}"); + fail("2^63 was accepted as a sequence. It is the same double as (double) " + + "Long.MAX_VALUE, so a range test written against that constant compares " + + "equal and clamps it back to Long.MAX_VALUE -- the exact poisoning the " + + "1e100 guard was added to stop, one value past where it looks."); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + try { StateCodec.fromJson("{\"device\":\"other\",\"seq\":1e100}"); fail("1e100 was accepted as a sequence -- clamped to Long.MAX_VALUE, it becomes this " @@ -4230,6 +4268,11 @@ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { // Fractional, which is the same harm in miniature: 5.7 becomes 5, so the sender's // own 5 is then indistinguishable from it. "{\"device\":\"other\",\"seq\":5.7}", + // 2^63, one past the range. It is the SAME double as (double) Long.MAX_VALUE -- + // that constant is not representable and rounds up to this -- so a "greater than + // Long.MAX_VALUE" test compares equal and lets it through, to be clamped straight + // back to Long.MAX_VALUE by the conversion. + "{\"device\":\"other\",\"seq\":9223372036854775808}", }; for (int i = 0; i < wrong.length; i++) { try { diff --git a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java index 88fbca58a37..10c368d5aaa 100644 --- a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java @@ -151,6 +151,47 @@ void navigateRollsTheStackBackWhenShowThrows() { "the failed navigation is reported as the current entry"); } + /** + * A stack the show callback CLEARED is not resurrected by the rollback. + * + *

The rollback exists so a screen the user never saw does not stay on the stack. But + * show() runs application code, and that code can navigate: the case that matters is a show + * listener discovering the session has expired, logging out -- which empties this stack on + * purpose -- and then throwing on the way out. Restoring unconditionally handed the + * signed-out account's forms straight back, reachable through getStack() and back(), and + * persisted by the next checkpoint. The rollback meant to help undid the one thing the + * logout existed to do.

+ */ + @FormTest + void aStackClearedByTheShowCallbackIsNotResurrected() { + Navigation.setDispatcher(new FakeDispatcher().route("/a").route("/b")); + Navigation.navigate("/a"); + Navigation.navigate("/b"); + assertTrue(baseline() >= 2, "the fixture did not build a stack to lose"); + + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + return new Form() { + public void show() { + // A logout discovered on screen, and then a failure on the way out. + Navigation.clearStack(); + throw new IllegalStateException("the session had expired"); + } + }; + } + }); + try { + Navigation.navigate("/whatever"); + fail("show() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller sees it, as it must. + } + + assertEquals(0, baseline(), + "the rollback put the cleared stack back, so the signed-out account's forms are " + + "reachable through back() again and the next checkpoint persists them"); + } + /** * A back whose showBack() throws puts the popped entry back. */ From 40cf4c74cb9ac164621bde84bda22b4b0aa8ab66 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:11:21 +0300 Subject: [PATCH 086/140] Ask the EDT for the window, and drop an arrival that came after disable() The cold-launch waiter read Display.getCurrent() from its own thread. That is not a field read: when the current form is a disposed dialog or a menu it walks animationQueue by index -- size taken first, then each element -- and a cold launch is exactly when the event thread is building forms and running transitions through that queue. An IndexOutOfBoundsException there was worse than it sounds: it escaped the worker before the notification back to the EDT, so waitingForWindow stayed set for the rest of the process, the arrival stayed parked, and no LATER arrival could start a waiter either. Marshalled rather than guarded. This framework is single threaded on the event thread and the UI belongs to it; the fix for touching it from elsewhere is to stop doing that, not to put a lock around state that has no business being shared. The notification back is now in a finally, so whatever happens in the loop the flag is cleared. The test that comes with it is a regression guard rather than a probe: with the worker no longer reading UI state, and haveWindow() swallowing whatever the event thread throws, the loop cannot throw at all, so nothing can distinguish the finally from its absence. What the test does pin is the behaviour the marshalling must keep -- an arrival is handed over once a window exists, and a SECOND arrival later in the run gets a waiter of its own, which is precisely what a stuck flag destroyed. Separately: `enabled == false` was covering two states that want opposite answers. Before the application's first enable() a continuation must be DECLINED, because the iOS port holds a declined activity and offers it again when a callback is next installed -- that is what recovers a cold-launch Handoff for an app that registers a SyncedStore listener before enabling. After an explicit disable() the same retention delivered a state from the interval disable() documents as ignored, whenever the app switched continuity back on. Claiming it is what discards it, since the port lets go of an activity that was handled, and nothing else answers to this application's own activity type. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 93 +++++++++++++++---- .../continuity/LocalContinuityTest.java | 72 ++++++++++++++ 2 files changed, 148 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6533674b479..2b0ba7cf0a2 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -221,6 +221,12 @@ public final class Continuity { /// delivering the previous account's state into the next account's screen. private static int relaySession; + /// Whether enable() has ever run in this process. It is NOT the negation of `enabled`: the + /// two states that share `enabled == false` -- never switched on yet, and switched off on + /// purpose -- want opposite answers for an arrival, and telling them apart is the whole + /// reason this exists. + private static boolean everEnabled; + /// The relay session a framework worker is running for, bound for the length of its call /// into the relay and unbound afterwards. Null on the event thread and on any thread the /// application drives itself, which is how mayRelaySend() tells the two apart. @@ -277,6 +283,7 @@ public static void enable() { } } enabled = true; + everEnabled = true; ContinuityBridge b = bridgeInternal(); if (b != null) { try { @@ -2085,8 +2092,9 @@ private static boolean isAlreadyActedOn(AppState state) { /// Holds a cold-launch arrival until the application has a form to restore into. /// /// The waiter is a thread only because there is nothing on the EDT to wait on -- no form - /// exists yet, so there is no timer to bind to. It touches no field of this class: it sleeps, - /// and hands the decision back to the event thread. + /// exists yet, so there is no timer to bind to. It touches no field of this class and no UI + /// state: it sleeps, asks the event thread whether a window has appeared, and hands the + /// decision back to it. private static void park(AppState state) { parked = state; if (waitingForWindow) { @@ -2097,27 +2105,64 @@ private static void park(AppState state) { @Override public void run() { long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS; - while (System.currentTimeMillis() < deadline) { - try { - Thread.sleep(100); - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - break; - } - if (Display.getInstance().getCurrent() != null) { - break; + try { + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(100); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + break; + } + if (haveWindow()) { + break; + } } + } finally { + // ALWAYS, whatever happened above. waitingForWindow is what stops a second + // waiter being started, so a throw that skipped this notification left it set + // for the rest of the process: the arrival stays parked, and every later + // arrival parks behind it without anything ever coming to look. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + windowWaitFinished(); + } + }); } - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - windowWaitFinished(); - } - }); } }, "Continuity window wait").start(); } + /// Whether a form is on screen, asked ON THE EVENT THREAD from the cold-launch waiter. + /// + /// `Display.getCurrent()` is not a plain field read. When the current form is a disposed + /// dialog or a menu it walks `animationQueue` by index -- size taken first, then each + /// element -- and a cold launch is precisely when the event thread is building forms and + /// running transitions through that queue. Reading it from this worker could throw + /// IndexOutOfBoundsException, which is worse than it sounds: the exception left + /// `waitingForWindow` set, so the arrival stayed parked and no later arrival could start a + /// waiter either. + /// + /// Marshalled rather than guarded. This framework is single threaded on the event thread and + /// the UI belongs to it; the fix for touching it from elsewhere is to stop doing that, not to + /// put a lock around state that has no business being shared. + private static boolean haveWindow() { + final boolean[] present = new boolean[1]; + try { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + present[0] = Display.getInstance().getCurrent() != null; + } + }); + } catch (Throwable t) { + // Answering "not yet" keeps the wait going, and the deadline still ends it. The + // caller's finally reports back either way. + Log.e(t); + } + return present[0]; + } + /// The cold-launch wait is over. On the EDT. private static void windowWaitFinished() { waitingForWindow = false; @@ -2741,6 +2786,7 @@ static void reset() { bridge = null; bridgeOverridden = false; enabled = false; + everEnabled = false; autoRestore = true; flushScheduled = false; title = null; @@ -2782,6 +2828,19 @@ public boolean continuationReceived(String activityType, Map use return false; } if (!enabled) { + if (everEnabled) { + // CLAIMED and dropped, because this is an explicit disable() rather than the + // window before the application's first enable(). The retention described + // below is what the two cases needed to be told apart for: declining here + // parked the arrival with the port, and the next enable() -- installing a + // callback is what makes the port re-offer it -- delivered a state from the + // interval disable() documents as ignored, sometimes long afterwards. + // + // Claiming rather than declining is what discards it: the port lets go of an + // activity that was handled. Nothing else answers to this application's own + // activity type, so taking it costs no other handler anything. + return true; + } // DECLINED while the framework is off, which is the answer the iOS port is built // for: it holds a declined activity and offers it again the next time a callback // is installed, and enable() installs one. Claiming it instead threw it away -- 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 index 8eda43a9f94..6252a8e72e5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3652,6 +3652,78 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A continuation arriving after an explicit disable() is DROPPED, not held for the next + * enable(). + * + *

The sibling above is the other half, and the two want opposite answers from the same + * {@code enabled == false}. Declining is right before the application's first enable(), + * because the port holds a declined activity and offers it again when a callback is next + * installed -- which is exactly what recovers a cold-launch Handoff. After an explicit + * disable() that same retention delivered a state from the interval disable() documents as + * ignored, whenever the application switched continuity back on.

+ * + *

Claiming is what discards it: the port lets go of an activity that was handled. Nothing + * else answers to this application's own activity type, so taking it costs no other handler + * anything.

+ */ + @EdtTest + public void aContinuationArrivingAfterDisableIsDroppedRatherThanHeld() { + Continuity.enable(); + Continuity.disable(); + ContinuityCallback callback = Continuity.callbackForTest(); + + Map info = StateCodec.toMap(fromElsewhere("during the off period", 91L)); + assertTrue(callback.continuationReceived(Continuity.getActivityType(), info), + "the callback declined a continuation after an explicit disable(), so the port " + + "holds it and the next enable() restores a state that arrived while " + + "the application had said it wanted none"); + + // Dropped, not delivered: claiming must not become a back door into the disabled + // framework either. + Continuity.enable(); + assertNull(Continuity.getRestorableState(), + "the arrival from the disabled interval was delivered after all"); + } + + /** + * The cold-launch waiter finishes, hands over, and leaves nothing set behind it. + * + *

The waiter asks the EVENT THREAD whether a window has appeared rather than reading + * Display.getCurrent() itself: that method is not a field read -- for a disposed dialog or a + * menu it walks animationQueue by index, size first and then each element -- and a cold + * launch is exactly when the event thread is building forms and running transitions through + * that queue. This test pins the behaviour the marshalling has to keep: the parked arrival is + * still handed over once a window exists, and {@code waitingForWindow} is left clear so a + * LATER arrival can start a waiter of its own. That second half is what a throw inside the + * old worker destroyed -- the flag stayed set for the rest of the process and every + * subsequent arrival parked behind it with nothing coming to look.

+ */ + @EdtTest + public void theColdLaunchWaiterHandsOverAndLeavesNothingSet() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + Continuity.parkForTest(fromElsewhere("first cold-launch arrival", 120L)); + Continuity.drainParkedForTest(); + AppState first = Continuity.getRestorableState(); + assertNotNull(first, "the first parked arrival was never handed over"); + assertEquals(120L, first.getSequence(), "a different state was handed over"); + Continuity.restore(first); + + // The SECOND is the point. A waiter that did not report back leaves waitingForWindow set, + // and this one is then parked for ever behind it. + Continuity.parkForTest(fromElsewhere("second arrival, later in the run", 121L)); + Continuity.drainParkedForTest(); + AppState second = Continuity.getRestorableState(); + assertNotNull(second, + "a second arrival was never handed over, which is what a waiter that failed to " + + "report back leaves behind: waitingForWindow set for the rest of the " + + "process and every later arrival parked behind it"); + assertEquals(121L, second.getSequence(), "the second arrival was not the one offered"); + } + /** * The eviction order of the delivery marks survives a restart. * From 5a40605f98326108a5cf653f0f4b852ef7313284 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:43:29 +0300 Subject: [PATCH 087/140] Reach the lifecycle guard from the catch too, and verify a delete before dropping the index Two of these are the same bug in two more places, and it is a bug I have already fixed once on this branch: a lifecycle guard placed where only the normal return reaches it. restore() checked the lifecycle after p.restoreState() but INSIDE the try, so a provider that called clear() and then threw -- cleanup breaking after it noticed an expired account -- was carried past by the catch, and the route rebuild ran for the session that had just ended. The later check does undo the stack, but only after that account's route factories, form constructors and show callbacks have run and put its data in front of the user. capture() had exactly this and was fixed by moving the check out of the try; restore() is the method that mirrors it and kept the mistake. The listener walk checked the lifecycle after the `continue`, so a listener that signed out and then threw jumped past and the NEXT listener was handed the signed-out account's state. The check at the end of dispatch stops the restore; it cannot undo what that listener did with the payload, or unsee it. One check now, before the continue, and the duplicate below it is gone. Enumerating every site where this class calls application code and then carries on -- saveState, restoreState, the listener walk, and the route rebuild that reaches application code indirectly through Navigation -- says those four are the whole set, and all four now guard on both exits. SyncedStore.notifyChanged was checked in the same pass and is a different shape: one listener throwing does not skip the others and no sequencing follows it. The simulated store dropped a key from its index whether or not the value was actually deleted, so a delete the platform refused left the old value readable through get() while keys() omitted it and clearing the store could not reach it. The delete is verified now, and when it cannot be verified the answer is "still there" -- an index entry for a value that has gone is a key whose get() answers the default, which an application can see and cope with, and the other way round is invisible. put()'s rollback had the identical unchecked delete and is fixed with it. That one is not in any report: it came out of enumerating the file after the first, which is the habit that keeps paying. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 56 +++++--- .../continuity/LocalContinuityBridge.java | 46 +++++- .../continuity/LocalContinuityTest.java | 134 ++++++++++++++++++ 3 files changed, 210 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 2b0ba7cf0a2..8159feb540b 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -901,26 +901,34 @@ private static boolean restore(final AppState state, boolean[] outFailed) { if (p != null) { try { p.restoreState(state.getPayload()); - if (lifecycle != lifecycleAtRestore) { - // The provider called clear() or disable(), which is the documented answer to - // "this payload belongs to a signed-out account". Everything below -- - // rebuilding routes, committing, persisting -- would act for a session that - // no longer exists, and would write back the state clear() has just deleted. - // - // The listener callback got this guard already; the provider is the OTHER - // application callback on this path and was missed. Both are places where an - // application is entitled to end the session, so both have to be asked - // afterwards whether it did. - outFailed[0] = true; - return false; - } // An empty payload is not an application. It is what a route-only state carries, - // and counting it would make the question above answer yes for every state. + // and counting it would make the question below answer yes for every state. applied = !state.getPayload().isEmpty(); } catch (Throwable t) { Log.e(t); failed = true; } + if (lifecycle != lifecycleAtRestore) { + // The provider called clear() or disable(), which is the documented answer to + // "this payload belongs to a signed-out account". Everything below -- + // rebuilding routes, committing, persisting -- would act for a session that + // no longer exists, and would write back the state clear() has just deleted. + // + // The listener callback got this guard already; the provider is the OTHER + // application callback on this path and was missed. Both are places where an + // application is entitled to end the session, so both have to be asked + // afterwards whether it did. + // + // OUTSIDE the try, so the CATCH reaches it too. Sitting on the normal-return + // path only, it was skipped by a provider that signed out and then threw -- + // cleanup breaking after it noticed an expired account -- and the route rebuild + // below ran for the session that had just ended. The later lifecycle check does + // undo the stack, but only after that account's route factories, form + // constructors and show callbacks have run and put its data on screen. The same + // mistake capture() had, in the method that mirrors it. + outFailed[0] = true; + return false; + } } List routes = usableRoutes(state.getRoutes()); if (routes.isEmpty()) { @@ -1982,19 +1990,29 @@ private static void dispatch(AppState state) { // otherwise mutate the list being walked. List snapshot = new ArrayList(listeners); for (ContinuityListener l : snapshot) { - boolean accepted; + boolean accepted = false; + boolean threw = false; try { accepted = l.stateReceived(state); } catch (Throwable t) { Log.e(t); - continue; + threw = true; } + // clear() or disable() ran inside the callback. Everything after this point -- + // asking the next listener, restoring, persisting, parking, marking -- would be + // acting for a session that no longer exists. + // + // ONE check, placed before the `continue`. It used to sit after it, so a listener + // that signed out and then THREW jumped straight past and the next listener was + // handed the signed-out account's state. The check at the bottom of this method + // stops the restore, but it cannot undo what that listener did with the payload, or + // unsee it. if (lifecycle != lifecycleAtDispatch) { - // clear() or disable() ran inside the callback. Everything after this point -- - // restoring, persisting, parking, marking -- would be acting for a session that - // no longer exists. return; } + if (threw) { + continue; + } if (!accepted) { // Consumed by the listener: it either handled the state itself or decided the user // must not be moved. Asking the next listener would undo that decision. diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index e333c4494a8..ecaa7f6eacb 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -216,10 +216,17 @@ public boolean syncedStorePut(String key, String value) { // // A failed write should leave nothing behind, which is the only answer that means // one thing. - try { - Storage.getInstance().deleteStorageFile(storageName(key)); - } catch (Throwable t) { - Log.e(t); + // + // VERIFIED, for the same reason syncedStoreRemove() verifies: an unchecked delete + // made the rollback claim a cleanup it had not performed. When it cannot be + // performed there is nothing further this simulation can do -- the index write + // that would have listed the value is the one that just failed -- so it is + // logged rather than passed over, because the store is then in the one state + // this class works to avoid. + if (!deleteValue(storageName(key))) { + Log.p("Continuity synced store: the value for a key whose index write failed " + + "could not be deleted either, so it stays readable through get() " + + "while keys() does not list it: " + key); } return false; } @@ -289,12 +296,37 @@ public String syncedStoreGet(String key) { return read(storageName(key)); } - @Override - public void syncedStoreRemove(String key) { + /// Deletes one stored value and reports whether it is DEFINITELY gone. + /// + /// The index and the values are two writes, and every caller here has to know which of them + /// happened. Dropping the index entry for a value the delete failed to remove leaves the old + /// value readable through get() while keys() omits it and clearing the store cannot reach it + /// -- a value with no way to see it and no way to remove it. + /// + /// When the check itself fails the answer is "still there", which is the safe direction: an + /// index entry for a value that has gone shows up as a key whose get() answers the default, + /// and an application can see that and cope. The other way round is invisible. + private boolean deleteValue(String name) { + try { + Storage.getInstance().deleteStorageFile(name); + } catch (Throwable t) { + Log.e(t); + } try { - Storage.getInstance().deleteStorageFile(storageName(key)); + return !Storage.getInstance().exists(name); } catch (Throwable t) { Log.e(t); + return false; + } + } + + @Override + public void syncedStoreRemove(String key) { + if (!deleteValue(storageName(key))) { + // The value is still readable, so the index keeps its entry. Reporting a key whose + // value is still there is the truth; dropping it would hide a value that get() goes + // on returning. + return; } List keys = indexKeys(); if (keys.remove(key)) { 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 index 6252a8e72e5..009dcb697f4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3652,6 +3652,140 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A synced-store value that would not delete keeps its index entry. + * + *

The two writes are the value and the index, and every caller has to know which of them + * happened. Dropping the index entry for a value the delete failed to remove left the old + * value readable through get() while keys() omitted it and clearing the store could not + * reach it -- a value with no way to see it and no way to remove it.

+ * + *

put()'s rollback had the identical unchecked delete: when the index write fails it + * removes the value it just wrote, and claimed a cleanup it had not performed. That one came + * out of enumerating the file rather than from the report.

+ */ + @EdtTest + public void aValueThatWillNotDeleteKeepsItsIndexEntry() { + Storage real = Storage.getInstance(); + try { + LocalContinuityBridge b = new LocalContinuityBridge(); + assertTrue(b.syncedStorePut("theme", "dark"), "the fixture could not write a value"); + assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("theme"), + "the fixture's own key is not listed, so this test is about nothing"); + + // A store whose delete does nothing at all, which is what a file the desktop cannot + // remove looks like from in here. + Storage.setStorageInstance(new UndeletableStorage(real)); + + b.syncedStoreRemove("theme"); + + assertEquals("dark", b.syncedStoreGet("theme"), + "the fixture is wrong: the value did go away, so there is no divergence to " + + "test"); + assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("theme"), + "the index dropped a key whose value is still readable through get(), so " + + "the value cannot be listed, cannot be found by a store-wide " + + "cleanup, and cannot be removed"); + } finally { + Storage.setStorageInstance(real); + new LocalContinuityBridge().syncedStoreRemove("theme"); + } + } + + /** + * A provider that signs out and then throws stops the restore before any route runs. + * + *

The guard sat on the normal-return path only, so a provider that called clear() and then + * failed -- cleanup breaking after it noticed an expired account -- was carried past it by + * the catch, and the route rebuild ran for the session that had just ended. The later + * lifecycle check does undo the stack, but only after that account's route factories, form + * constructors and show callbacks have run and put its data in front of the user.

+ * + *

The same mistake capture() had, in the method that mirrors it. Found there first, and + * still here.

+ */ + @EdtTest + public void aProviderThatSignsOutAndThenThrowsStopsBeforeTheRoutes() { + final java.util.concurrent.atomic.AtomicInteger routesBuilt = + new java.util.concurrent.atomic.AtomicInteger(); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + routesBuilt.incrementAndGet(); + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + // The documented answer to "this payload belongs to a signed-out account", + // and then a failure on the way out of it. + Continuity.clear(); + throw new IllegalStateException("the cleanup after the logout failed"); + } + }); + Continuity.setAutoRestore(false); + + Map payload = new HashMap(); + payload.put("account", "the previous one"); + AppState arriving = new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/orders", "/orders/17")) + .setDeviceId("some-other-device") + .setSequence(140L) + .setTimestamp(System.currentTimeMillis()); + Continuity.restore(arriving); + flushSerialCalls(); + + assertEquals(0, routesBuilt.get(), + "the signed-out account's routes were dispatched anyway: its route factories, " + + "form constructors and show callbacks all ran, and undoing the " + + "stack afterwards cannot unrun them"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A listener that signs out and then throws is not followed by the next listener. + * + *

The check was after the {@code continue}, so a throw jumped straight past it and the + * next listener was handed the signed-out account's state. The check at the end of dispatch + * stops the restore, but it cannot undo what that listener did with the payload, or unsee + * it.

+ */ + @EdtTest + public void aListenerThatSignsOutAndThenThrowsStopsTheWalk() { + final java.util.concurrent.atomic.AtomicInteger secondSaw = + new java.util.concurrent.atomic.AtomicInteger(); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + Continuity.clear(); + throw new IllegalStateException("the cleanup after the logout failed"); + } + }); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + secondSaw.incrementAndGet(); + return true; + } + }); + + Continuity.deliver(fromElsewhere("the previous account's work", 141L)); + flushSerialCalls(); + + assertEquals(0, secondSaw.get(), + "the second listener was handed a state from a session the first had already " + + "ended, and nothing afterwards can unsee it"); + } + /** * A continuation arriving after an explicit disable() is DROPPED, not held for the next * enable(). From 3d04f18f5a28505bf914c16128ba1637394dd947 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:10:52 +0300 Subject: [PATCH 088/140] Refuse a raw control character, bind the queued dispatch, stop listing a gone key The grammar check accepted every unescaped character except quote and backslash, and JSON allows neither below U+0020. Not a formality: the framework parser appends a raw control byte to whatever it is building rather than stopping, so a document carrying a literal newline inside a KEY -- "payload" -- passed as valid and came out with a key that is not "payload". The field is unknown and dropped, and a state with no payload and no routes is a tombstone, which the sending device meant as nothing of the sort. No conformant encoder emits one, so nothing legitimate is refused; the escaped form still round-trips, which the test asserts alongside the refusal. The queued dispatch checked `enabled` and could not see a disable() and an enable() that BOTH ran before it did -- two queued turns are enough, and the flag is true again by the time it is read. lastSeen still holds the sequence, so the supersession check waved it through too, and an arrival from before the disable was restored after all. It carries the lifecycle generation from admission now, which is the field that remembers a session ended. And the other direction of the last commit's store fix: with the value deleted and the index write then failing, the stored index goes on naming a key whose get() answers the default. keys() filters by what is actually stored, so the listing stays truthful for the rest of the process, and a positive absence is the only thing that removes an entry -- when the check cannot be made the key stays, which is the same direction deleteValue() chose and for the same reason. The platform being simulated has no such gap at all: NSUbiquitousKeyValueStore enumerates its own dictionary, so a phantom key cannot exist there, and the simulation should not invent one. The failed index write is logged rather than passed over. The test for it drives the real scenario -- every write failing while deletes and reads still work -- rather than deleting a file by a name it had to reconstruct, which would have needed a public seam for the key escaping. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 12 ++ .../com/codename1/continuity/StateCodec.java | 12 ++ .../continuity/LocalContinuityBridge.java | 40 ++++- .../continuity/LocalContinuityTest.java | 140 ++++++++++++++++++ 4 files changed, 201 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 8159feb540b..38aeec57577 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1938,6 +1938,13 @@ private static void admit(final AppState state) { rememberSeen(); return; } + // The generation as it stands at admission. `enabled` alone could not see a disable() + // and an enable() that BOTH ran before this runnable did -- two queued turns are enough, + // and the flag is true again by the time it is read -- so the arrival from before the + // disable was dispatched and restored after all. lastSeen still holds its sequence, so + // the supersession check below waves it through too. The generation is the field that + // remembers a session ended, which is what the promise is actually about. + final int lifecycleAtAdmission = lifecycle; Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1946,6 +1953,11 @@ public void run() { // moment it is called, including the ones already admitted. return; } + if (lifecycle != lifecycleAtAdmission) { + // The session this arrival was admitted into has ended, whether or not one + // has been started since. + return; + } Long newest = lastSeen.get(state.getDeviceId()); if (newest == null || newest.longValue() != state.getSequence()) { // Superseded while this was queued: a newer state from the same device was diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 318674a5a1f..619c83923e1 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -507,6 +507,18 @@ private static boolean scanString(String s, int[] at) { if (c == '"') { return true; } + if (c < ' ') { + // JSON forbids an unescaped character below U+0020 inside a string, and this + // check accepted every one of them. It is not a formality: the framework parser + // appends a raw control byte to whatever it is building rather than stopping, so + // a document carrying a literal newline INSIDE a key -- "payload" -- passed + // as valid and came out with a key that is not "payload". The field is then + // unknown and dropped, and a state with no payload and no routes is a tombstone: + // the sending device is read as having cleared its work. + // + // No conformant encoder emits one, so nothing legitimate is refused by this. + return false; + } if (c == '\\') { if (at[0] >= s.length()) { return false; diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index ecaa7f6eacb..86f79cfdbf5 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -329,15 +329,49 @@ public void syncedStoreRemove(String key) { return; } List keys = indexKeys(); - if (keys.remove(key)) { - writeIndex(keys); + if (keys.remove(key) && !writeIndex(keys)) { + // The value is gone and the index still lists it. syncedStoreKeys() filters that out + // so keys() stays truthful for the rest of the process, and this says so once rather + // than leaving a durable index that disagrees with the store entirely unremarked. + Log.p("Continuity synced store: the value for \"" + key + "\" was removed but the " + + "index could not be rewritten, so the stored index still lists it until a " + + "later write succeeds."); } } @Override public String[] syncedStoreKeys() { + // FILTERED by what is actually stored, because the index is a second write and can be + // left describing a value that is not there: a delete that succeeded and an index write + // that then failed leaves the key listed while get() answers the default. The platform + // this simulates has no such gap -- NSUbiquitousKeyValueStore enumerates its own + // dictionary, so a phantom key cannot exist there -- and the simulation should not + // invent one. + // + // Only a POSITIVE absence removes a key. When the check cannot be made the entry stays, + // which is the same direction deleteValue() chose and for the same reason: a listed key + // whose get() answers the default is visible and survivable, and a value nothing lists + // is neither. List keys = indexKeys(); - return keys.toArray(new String[keys.size()]); + List present = new ArrayList(); + for (String key : keys) { + if (definitelyAbsent(storageName(key))) { + continue; + } + present.add(key); + } + return present.toArray(new String[present.size()]); + } + + /// Whether this storage name is known NOT to hold a value. False when it does, and false + /// when the question could not be answered. + private boolean definitelyAbsent(String name) { + try { + return !Storage.getInstance().exists(name); + } catch (Throwable t) { + Log.e(t); + return false; + } } /// Reports a change made "on another device", which the Simulate menu uses to exercise an 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 index 009dcb697f4..4575a8d0f2a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3652,6 +3652,111 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A raw control character inside a JSON string is a failed read, not a tombstone. + * + *

The grammar check accepted every unescaped character except quote and backslash, and + * JSON allows neither below U+0020. It is not a formality: the framework parser appends a + * raw control byte to whatever it is building rather than stopping, so a document carrying a + * literal newline inside a KEY -- "pay(LF)load" -- passed as valid and came out with a key + * that is not "payload". The field is unknown and dropped, and a state with no payload and + * no routes is a tombstone, which the sending device meant as nothing of the sort.

+ */ + @EdtTest + public void aRawControlCharacterInAStringIsRefused() throws Exception { + String withNewlineInAKey = + "{\"device\":\"other\",\"seq\":\"10\",\"pay\nload\":{\"a\":1}}"; + try { + AppState s = StateCodec.fromJson(withNewlineInAKey); + fail("a raw newline inside a JSON string was accepted" + + (s != null && s.isEmpty() + ? " -- and as an EMPTY state, which is read as a tombstone: the " + + "sending device is told to have cleared its work" + : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + + // The ESCAPED form is the legitimate one and must still go through, or this guard would + // refuse every payload that contains a line break. + AppState fine = StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"10\",\"enc\":\"1\"," + + "\"payload\":{\"note\":\"s:two\\nlines\"}}"); + assertEquals("two\nlines", fine.getPayload().get("note"), + "an escaped newline was mangled, so the guard refuses legitimate documents"); + } + + /** + * An arrival admitted before disable() is not dispatched by an enable() that follows. + * + *

{@code enabled} alone could not see a disable() and an enable() that BOTH ran before the + * queued dispatch did -- two queued turns are enough, and the flag is true again by the time + * it is read -- so the arrival from before the disable was dispatched and restored after + * all. lastSeen still holds its sequence, so the supersession check waves it through too. The + * generation is the field that remembers a session ended, which is what disable() actually + * promises.

+ */ + @EdtTest + public void anArrivalAdmittedBeforeDisableIsNotDispatchedByALaterEnable() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + + // Admitted, which queues the dispatch for the next turn. + Continuity.deliver(fromElsewhere("the previous session's work", 150L)); + + // Both land BEFORE that queued turn runs, which is what the flag could not see. + Display.getInstance().callSerially(new Runnable() { + public void run() { + Continuity.disable(); + Continuity.enable(); + } + }); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "a state admitted before disable() was restored by the enable() that followed, " + + "though disable() documents that arriving states are ignored from the " + + "moment it is called, including the ones already admitted"); + } + + /** + * A key whose value is gone is not listed, even when the index still names it. + * + *

The sibling of the deletion-failure case, in the other direction: the value IS deleted + * and the index write then fails, so the stored index goes on naming a key whose get() + * answers the default. The platform being simulated has no such gap -- + * NSUbiquitousKeyValueStore enumerates its own dictionary, so a phantom key cannot exist + * there -- and the simulation should not invent one.

+ */ + @EdtTest + public void aKeyWhoseValueIsGoneIsNotListed() { + Storage real = Storage.getInstance(); + LocalContinuityBridge b = new LocalContinuityBridge(); + try { + assertTrue(b.syncedStorePut("locale", "en"), "the fixture could not write a value"); + assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("locale"), + "the fixture's own key is not listed, so this test is about nothing"); + + // The scenario itself rather than a hand-made facsimile of it: from here every write + // fails while deletes and reads still work, so the remove below deletes the value + // successfully and cannot rewrite the index. + Storage.setStorageInstance(new WriteRefusingStorage(real)); + b.syncedStoreRemove("locale"); + assertNull(b.syncedStoreGet("locale"), + "the fixture is wrong: the value survived, so there is no phantom entry to " + + "test"); + + assertFalse(java.util.Arrays.asList(b.syncedStoreKeys()).contains("locale"), + "keys() named a key whose value is not there, so an application walking the " + + "store reads the default for a key the store says it has"); + } finally { + Storage.setStorageInstance(real); + new LocalContinuityBridge().syncedStoreRemove("locale"); + } + } + /** * A synced-store value that would not delete keeps its index entry. * @@ -4479,6 +4584,11 @@ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception { // Long.MAX_VALUE" test compares equal and lets it through, to be clamped straight // back to Long.MAX_VALUE by the conversion. "{\"device\":\"other\",\"seq\":9223372036854775808}", + // A raw control character INSIDE a string. JSON forbids it unescaped, and the + // framework parser appends it rather than stopping -- so the key here is not + // "payload", the field is dropped as unknown, and what is left is a tombstone. + "{\"device\":\"other\",\"seq\":\"10\",\"pay\nload\":{\"a\":1}}", + "{\"device\":\"other\",\"seq\":\"10\",\"title\":\"two\u0000parts\"}", }; for (int i = 0; i < wrong.length; i++) { try { @@ -4556,6 +4666,36 @@ public void aRelayFieldThatIsPresentAndNullIsAFailedRead() throws Exception { } /** Storage that refuses ONE name and passes everything else through. */ + /** Storage whose writes all fail while reads and deletes still work -- a full disk, from + * the point of view of code that has to keep two writes in step. */ + static class WriteRefusingStorage extends Storage { + private final Storage delegate; + + WriteRefusingStorage(Storage delegate) { + this.delegate = delegate; + } + + @Override + public boolean writeObject(String name, Object o) { + return false; + } + + @Override + public Object readObject(String name) { + return delegate.readObject(name); + } + + @Override + public boolean exists(String name) { + return delegate.exists(name); + } + + @Override + public void deleteStorageFile(String name) { + delegate.deleteStorageFile(name); + } + } + static class RefusingOneStorage extends Storage { private final Storage delegate; private final String refused; From dbed9f57872898104e58d1d6a2239661bbcf63a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:58 +0300 Subject: [PATCH 089/140] Keep the stack agreeing with the display, and validate the other way in Three findings, two of them gaps left by my own fixes one and two commits ago. The rollback for a navigation whose show() threw rolled back regardless of whether the form had been installed. show() installs the form and only THEN runs onShowCompleted and the show listeners, so a throw from one of those is a failure that happened after the navigation succeeded: the entry describes the screen the user is looking at, and rolling it back left Navigation.getCurrent() disagreeing with Display.getCurrent() -- back() working on a stack whose top is not the visible form, and a checkpoint persisting a screen the user is not on. Fixed by rolling back ONLY when the intended form is not the one on screen, rather than by re-showing the previous form as the report suggested. Re-showing runs a second full show cycle -- transitions, listeners, whatever they do -- as error handling, on a form the application has not asked to see again, and that cycle can throw in its turn. Leaving the stack agreeing with the display costs nothing and needs no application code to run. The reasoning is in the code, where it will be read. disable() returned early when there was nothing to turn off, so it never recorded the application's choice. An app that enables continuity only after a login and calls disable() while logged out was leaving the flag unset, and an arrival during that interval was read as a pre-enable cold-launch arrival: declined, retained by the port, and delivered by the enable() that came with the login. Saying "no" before saying anything else is still saying it. The flag is renamed to what it now means -- the application has chosen, either way -- since "everEnabled" is exactly the reading that produced the bug. One test fixture called disable() to force an off state before the first enable. That was harmless while disable() was a no-op there and wrong once it began recording a choice, so it now relies on the per-test reset for silence. Silence and an explicit "off" are different answers and there is a test for each. And Continuity.Callback calls StateCodec.fromMap() directly -- an NSUserActivity, or anything a custom bridge hands over, never touches fromJson -- so every schema check added for the relay wire was missing from the platform continuation path. A continuation with a good origin and sequence but "routes" as a string dropped the field, produced an empty state, and admission consumed that as a tombstone and advanced the origin's durable high-water mark. The check moved into fromMap, which is the one place both ways in pass through; it answers null there, which every caller already handles, and fromJson keeps its throwing check so a bad fetch is still reported as a failed read rather than as an empty relay. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 31 ++++++--- .../com/codename1/continuity/StateCodec.java | 18 +++++ .../src/com/codename1/router/Navigation.java | 27 ++++++-- .../continuity/LocalContinuityTest.java | 66 ++++++++++++++++++- .../com/codename1/router/NavigationTest.java | 49 ++++++++++++++ 5 files changed, 175 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 38aeec57577..f506fd9859c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -221,11 +221,15 @@ public final class Continuity { /// delivering the previous account's state into the next account's screen. private static int relaySession; - /// Whether enable() has ever run in this process. It is NOT the negation of `enabled`: the - /// two states that share `enabled == false` -- never switched on yet, and switched off on - /// purpose -- want opposite answers for an arrival, and telling them apart is the whole - /// reason this exists. - private static boolean everEnabled; + /// Whether the application has said anything about continuity yet -- either enable() or + /// disable(). It is NOT the negation of `enabled`: the two states that share + /// `enabled == false` -- nothing said yet, and switched off on purpose -- want opposite + /// answers for an arrival, and telling them apart is the whole reason this exists. + /// + /// Set by disable() as well as enable(), because "no" is an answer. Reading it as + /// "enable() has run" left an application that only enables after a login treating its own + /// explicit disable() as though it had never spoken. + private static boolean applicationHasChosen; /// The relay session a framework worker is running for, bound for the length of its call /// into the relay and unbound afterwards. Null on the event thread and on any thread the @@ -283,7 +287,7 @@ public static void enable() { } } enabled = true; - everEnabled = true; + applicationHasChosen = true; ContinuityBridge b = bridgeInternal(); if (b != null) { try { @@ -298,6 +302,12 @@ public static void enable() { /// arriving states are ignored. What is already in storage is left alone -- use `clear()` to /// remove it. public static void disable() { + // The CHOICE is recorded whether or not there was anything to turn off. An application + // that enables continuity only after a login and calls disable() while logged out was + // leaving this flag false, so an arrival during that interval was read as a pre-enable + // cold-launch arrival: declined, retained by the port, and delivered by the enable() + // that came with the login. Saying "no" before saying anything else is still saying it. + applicationHasChosen = true; if (!enabled) { return; } @@ -2816,7 +2826,7 @@ static void reset() { bridge = null; bridgeOverridden = false; enabled = false; - everEnabled = false; + applicationHasChosen = false; autoRestore = true; flushScheduled = false; title = null; @@ -2858,9 +2868,10 @@ public boolean continuationReceived(String activityType, Map use return false; } if (!enabled) { - if (everEnabled) { - // CLAIMED and dropped, because this is an explicit disable() rather than the - // window before the application's first enable(). The retention described + if (applicationHasChosen) { + // CLAIMED and dropped, because the application has said what it wants and + // right now that is "off" -- rather than the window before it has said + // anything at all. The retention described // below is what the two cases needed to be told apart for: declining here // parked the arrival with the port, and the next enable() -- installing a // callback is what makes the port re-offer it -- delivered a state from the diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 619c83923e1..1b0a660fde5 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -133,6 +133,24 @@ public static AppState fromMap(Map m) { if (!recognized) { return null; } + try { + // HERE, not only in fromJson. A platform continuation -- an NSUserActivity, or + // anything a custom bridge hands over -- reaches this method DIRECTLY, so every + // schema check added for the relay wire was missing from the other way in. A + // continuation with a good origin and sequence but "routes" as a string dropped the + // field, produced an empty state, and admission consumed that as a tombstone and + // advanced the origin's durable high-water mark: the same harm, on the path that + // never touches JSON. + // + // NULL rather than an exception, because this is the answer every caller of this + // method already handles and the continuation callback is not a place to throw. + // fromJson keeps its own throwing check so a bad fetch is reported as a failed read + // rather than as an empty relay. + requireKnownTypes(m); + } catch (IOException malformed) { + Log.e(malformed); + return null; + } AppState state = new AppState(); Object routes = m.get(KEY_ROUTES); if (routes instanceof List) { diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 1a7d2c4e4b7..2cb1198a799 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -121,7 +121,7 @@ public static boolean navigate(String path) { // this method has already queued, and restored after a process death. The flush reads // the stack when it runs, so putting it back is what makes that checkpoint describe // the truth. - rollBack(before, expected); + rollBack(before, expected, f); throw e; } return true; @@ -145,7 +145,7 @@ public static boolean back() { try { back.showBack(); } catch (RuntimeException e) { - rollBack(before, expected); + rollBack(before, expected, back); throw e; } return true; @@ -178,10 +178,29 @@ public static List getStack() { /// deliberate change by code that ran later, and it wins. `NavigationEntry` does not override /// equals, so comparing the lists compares entry IDENTITY, which is what makes "still exactly /// what I left" mean what it says. - private static void rollBack(List before, List expected) { + private static void rollBack(List before, List expected, + Form intended) { if (!expected.equals(stack)) { return; } + if (Display.getInstance().getCurrent() == intended) { //NOPMD CompareObjectsWithEquals + // The form IS on screen. show() installs the form and only then runs onShowCompleted + // and the show listeners, so a throw from one of those is a failure that happened + // AFTER the navigation succeeded -- the entry describes the screen the user is + // looking at, and rolling it back would leave Navigation.getCurrent() disagreeing + // with Display.getCurrent(): back() would work on a stack whose top is not the + // visible form, and a checkpoint would persist a screen the user is not on. + // + // The rollback is for the other case, which is the one it was written for: show() + // threw BEFORE installing anything, so the entry is a screen nobody ever saw. + // + // Re-showing the previous form instead was the alternative, and it is worse: it runs + // a second full show cycle -- transitions, listeners, whatever they do -- as error + // handling, on a form the application has not asked to see again, and that cycle can + // throw in its turn. Leaving the stack agreeing with the display costs nothing and + // needs no application code to run. + return; + } stack.clear(); stack.addAll(before); } @@ -244,7 +263,7 @@ public static boolean popTo(NavigationEntry entry) { try { target.showBack(); } catch (RuntimeException e) { - rollBack(before, expected); + rollBack(before, expected, target); throw e; } return true; 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 index 4575a8d0f2a..9e5b4f7524d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3634,8 +3634,11 @@ public void forgettingTheBackHistoryIsCheckpointed() { */ @EdtTest public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { - // Deliberately NOT enabled: this is the window the port retains for. - Continuity.disable(); + // NOTHING SAID YET, which is the window the port retains for, and which the per-test + // reset() already gives. This used to call disable() to force the state -- harmless when + // disable() was a no-op before the first enable(), and wrong once it began recording the + // application's choice: an explicit "off" is a different answer from silence, and the + // sibling test below is about that one. ContinuityCallback callback = Continuity.callbackForTest(); Map info = StateCodec.toMap(fromElsewhere("from the other device", 88L)); @@ -3652,6 +3655,65 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A disable() before any enable() is still an answer, and an arrival during it is dropped. + * + *

The gap the previous fix left. An application that enables continuity only after a login + * and calls disable() while logged out was leaving the flag unset, because disable() returned + * early when there was nothing to turn off -- so an arrival during that interval was read as + * a pre-enable cold-launch arrival: declined, retained by the port, and delivered by the + * enable() that came with the login. Saying "no" before saying anything else is still saying + * it.

+ */ + @EdtTest + public void aDisableBeforeAnyEnableStillDropsAnArrival() { + // Never enabled. This is the whole point: disable() has nothing to turn off here. + Continuity.disable(); + ContinuityCallback callback = Continuity.callbackForTest(); + + Map info = StateCodec.toMap(fromElsewhere("during the logged-out spell", 92L)); + assertTrue(callback.continuationReceived(Continuity.getActivityType(), info), + "the callback declined an arrival after an explicit disable() that happened to be " + + "the application's FIRST word, so the port holds it and the enable() " + + "that comes with the login restores it"); + + Continuity.enable(); + assertNull(Continuity.getRestorableState(), + "the arrival from the disabled interval was delivered after all"); + } + + /** + * A platform continuation gets the same schema check the relay wire gets. + * + *

Continuity.Callback calls fromMap() DIRECTLY -- an NSUserActivity, or anything a custom + * bridge hands over, never touches fromJson -- so every check added for the relay was missing + * from the other way in. A continuation with a good origin and sequence but "routes" as a + * string dropped the field, produced an empty state, and admission consumed that as a + * tombstone and advanced the origin's durable high-water mark. Same harm, other path.

+ */ + @EdtTest + public void aMalformedPlatformContinuationIsNotConsumedAsATombstone() { + Continuity.enable(); + Continuity.setStateProvider(new RecordingProvider()); + ContinuityCallback callback = Continuity.callbackForTest(); + + Map malformed = new HashMap(); + malformed.put("device", "bridge-sender"); + malformed.put("seq", "10"); + // A LIST is what this field is; a string here is the malformed case, and it used to be + // dropped in silence. + malformed.put("routes", "/orders,/orders/17"); + + assertFalse(callback.continuationReceived(Continuity.getActivityType(), malformed), + "a malformed continuation was claimed, so the framework took responsibility for " + + "a document it could not read"); + flushSerialCalls(); + + assertNull(Continuity.readSeenForTest().get("bridge-sender"), + "the malformed continuation was consumed as a tombstone and marked durably, so " + + "the sender's correction is refused after a restart as already seen"); + } + /** * A raw control character inside a JSON string is a failed read, not a tombstone. * diff --git a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java index 10c368d5aaa..2315abbc20d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java @@ -24,6 +24,7 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; import com.codename1.ui.Form; import java.util.HashMap; @@ -151,6 +152,54 @@ void navigateRollsTheStackBackWhenShowThrows() { "the failed navigation is reported as the current entry"); } + /** + * A form that DID get shown keeps its stack entry, so the stack and the display agree. + * + *

show() installs the form and only then runs onShowCompleted and the show listeners, so a + * throw from one of those is a failure that happened after the navigation succeeded. Rolling + * the entry back regardless left Navigation.getCurrent() disagreeing with + * Display.getCurrent(): back() worked on a stack whose top was not the visible form, and a + * checkpoint persisted a screen the user was not on.

+ * + *

Not fixed by re-showing the previous form, which runs a second full show cycle -- + * transitions, listeners, whatever they do -- as error handling, on a form the application + * has not asked to see again, and which can throw in its turn.

+ */ + @FormTest + void aFormThatWasShownKeepsItsEntryWhenItsListenerThrows() { + Navigation.setDispatcher(new FakeDispatcher().route("/a")); + Navigation.navigate("/a"); + int before = baseline(); + + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + // The form is already installed by the time this runs. + throw new IllegalStateException("the show listener failed"); + } + }); + return f; + } + }); + try { + Navigation.navigate("/shown-then-throws"); + fail("the show listener did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller still sees it. + } + + assertEquals(before + 1, baseline(), + "the entry for a form that IS on screen was rolled back, so the stack no longer " + + "describes what the user is looking at"); + assertSame(Display.getInstance().getCurrent(), Navigation.getCurrent().getForm(), + "Navigation.getCurrent() and Display.getCurrent() disagree, so back() works on a " + + "stack whose top is not the visible form and a checkpoint persists a " + + "screen the user is not on"); + } + /** * A stack the show callback CLEARED is not resurrected by the rollback. * From a39c2736e8cf882f3d23fb74a7ce821cf9540851 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:13:57 +0300 Subject: [PATCH 090/140] Refuse an oversized title at the call, and keep a thrown route rebuild pending A route rebuild that THREW was treated the same as routes that would not rebuild, and the two are different. The orderly case is safe to acknowledge: this build no longer registers those routes, they will not start working on the next launch, and the payload already worked on this one -- that is what the existing comment argues and it is right. A throw says nothing of the kind. It is the transient breakage a provider that throws gets, a dependency not up yet on a cold launch, which this method already treats as retryable. Independent of whether the payload applied, which is what let it through: with the payload taken the failure branch did not fire at all, so a state whose route was never shown was persisted and ACKNOWLEDGED -- the relay's only other copy released while the user is not on the restored screen, and the next navigation overwriting both. Restoring twice is a smaller harm than losing the work. setTitle() accepted a label AppState.setTitle() rejects, so the failure surfaced from the next checkpoint instead -- and nothing catches that: `dirty` is assigned after capture() returns, so it stayed set and every later navigation retried the same failing capture. Nothing was stored or published again and the application was never told why. Refused at the call now. A local route too long to store does the same thing and has no such call to refuse it at: Navigation is a general routing API and must not reject a path because continuity could not store it. capture() runs the routes through usableRoutes(), the filter written for the inbound path -- whose own comment describes this exact failure, and which prevented it only for routes that arrived from another device. One more site has the same shape and is deliberately left alone: an unrepresentable payload still throws out of capture(), which an in-code comment argues for as a programming error with one correct moment to surface. That reasoning holds for an unrepresentable TYPE and is weaker for an oversized STRING, which is data-dependent and can first appear in production. Reversing it is a design change rather than a fix, so it is flagged rather than made here. Also fixes SimplifyBooleanReturns, which build-test (8) caught in the callback this branch rewrote two commits ago. My local PMD check had been reading a five-rule list of my own rather than the forty-nine in generate-quality-report.py; it now runs that script. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 86 +++++++++---- .../continuity/LocalContinuityTest.java | 114 ++++++++++++++++++ 2 files changed, 176 insertions(+), 24 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index f506fd9859c..26dbfaef3c0 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -467,7 +467,21 @@ public static boolean isAutoRestore() { /// #### Parameters /// /// - `t`: the label, or null for none + /// + /// #### Throws + /// + /// - `java.lang.IllegalArgumentException`: when the label is longer than a stored checkpoint + /// can hold public static void setTitle(String t) { + if (t != null) { + // HERE, synchronously, because the alternative is not "it fails later" but "nothing + // works again". capture() builds the AppState through the validating setter, so an + // oversized label threw out of the next checkpoint -- which nothing catches: `dirty` + // is assigned after capture() returns, so it stayed set, and every later navigation + // retried the same failing capture. Nothing was stored or published again, and the + // application was never told why. + StateCodec.requireWritable(t, "title"); + } title = t; } @@ -690,7 +704,18 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile int lifecycleAtCapture = lifecycle; StateProvider p = provider; AppState state = new AppState(); - state.setRoutes(currentRoutes()); + // usableRoutes(), the filter written for the INBOUND path, applied here for the same + // reason at the other end. Its own comment describes this exact failure -- a route past + // the stored-string limit throws out of capture(), leaves the pending flag set, and every + // later navigation retries the same throw while nothing is persisted or published -- and + // it prevented it only for routes that arrived from another device. A long deep link the + // application navigated to itself reaches the stack the same way and ends every + // checkpoint this process would ever make. + // + // Not stoppable earlier: Navigation is a general routing API and must not refuse a path + // because continuity could not store it. Dropping the route and saying so once is the + // lesser loss. + state.setRoutes(usableRoutes(currentRoutes())); if (p != null) { Map payload = null; try { @@ -969,12 +994,14 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // cancelled restore left the signed-out account's SCREEN in front of the user. com.codename1.ui.Form beforeRestore = Display.getInstance().getCurrent(); boolean shown; + boolean routesThrew = false; applyingRestore = true; try { shown = Navigation.restoreStack(routes); } catch (Throwable t) { Log.e(t); shown = false; + routesThrew = true; } finally { applyingRestore = false; } @@ -1022,7 +1049,21 @@ private static boolean restore(final AppState state, boolean[] outFailed) { outFailed[0] = true; return false; } - if (!shown && !applied) { + if (routesThrew) { + // A THROW, which is a different thing from routes that would not rebuild, and the two + // were collapsed here. The reasoning below is about the orderly case: this build no + // longer registers those routes, they will not start working on the next launch, and + // the payload already worked on this one. A throw says nothing of the kind -- it is + // the same transient breakage a provider that throws gets, a dependency not up yet on + // a cold launch, which this method already treats as retryable. + // + // Independent of `applied`, which is what let it through: with the payload taken the + // branch below did not fire, so a state whose route was never shown was persisted and + // ACKNOWLEDGED -- the relay's only other copy released while the user is not on the + // restored screen, and the next navigation overwriting both. Restoring twice is a + // smaller harm than losing the work. + failed = true; + } else if (!shown && !applied) { // Routes were named, none could be rebuilt, and nothing else in the state applied // either -- an attempt that failed outright, so it stays on the relay for a launch // that can use it. @@ -2868,37 +2909,34 @@ public boolean continuationReceived(String activityType, Map use return false; } if (!enabled) { - if (applicationHasChosen) { - // CLAIMED and dropped, because the application has said what it wants and - // right now that is "off" -- rather than the window before it has said - // anything at all. The retention described - // below is what the two cases needed to be told apart for: declining here - // parked the arrival with the port, and the next enable() -- installing a - // callback is what makes the port re-offer it -- delivered a state from the - // interval disable() documents as ignored, sometimes long afterwards. - // - // Claiming rather than declining is what discards it: the port lets go of an - // activity that was handled. Nothing else answers to this application's own - // activity type, so taking it costs no other handler anything. - return true; - } - // DECLINED while the framework is off, which is the answer the iOS port is built - // for: it holds a declined activity and offers it again the next time a callback - // is installed, and enable() installs one. Claiming it instead threw it away -- - // admit() drops an arrival when the framework is disabled, so an application that + // The answer is the application's own choice, and the two states that share + // `enabled == false` want opposite ones. + // + // TRUE -- claimed, and therefore dropped -- when the application has said what it + // wants and right now that is "off". The port lets go of an activity that was + // handled, and nothing else answers to this application's own activity type, so + // taking it costs no other handler anything. Declining here instead parked the + // arrival with the port, and the next enable() -- installing a callback is what + // makes the port re-offer it -- delivered a state from the interval disable() + // documents as ignored. + // + // FALSE -- declined, and therefore RETAINED -- while the application has said + // nothing at all. That is the answer the iOS port is built for: it holds a + // declined activity and offers it again the next time a callback is installed, + // and enable() installs one. Claiming it instead threw it away, because admit() + // drops an arrival while the framework is disabled -- so an application that // registers a SyncedStore listener before enabling continuity, which installs // this same callback, lost a cold-launch Handoff for good. // // The two sides disagreed rather than one being wrong: this claimed everything of // its own type so no other handler could take it, while the port's retention was - // written for a decline that never came. Declining is strictly better, because - // nothing else answers to this app's own activity type anyway. + // written for a decline that never came. // - // `enabled` is read here from the platform's thread, which the rest of this + // Both flags are read here from the platform's thread, which the rest of this // method deliberately avoids. It is safe in the one direction that matters: a // decline is RECOVERABLE -- the activity is retained and re-offered -- so losing // the race can only delay the delivery, never lose it. - return false; + return applicationHasChosen; } AppState state = StateCodec.fromMap(userInfo); if (state == null) { 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 index 9e5b4f7524d..371a94b22ef 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,111 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A route rebuild that THREW keeps the state pending, even when the payload applied. + * + *

A throw is a different thing from routes that would not rebuild, and the two were + * collapsed. The orderly case is safe to acknowledge -- this build no longer registers those + * routes, they will not start working next launch, and the payload already worked on this + * one. A throw is the transient breakage a provider that throws gets, and with the payload + * taken the failure branch did not fire at all: the state was persisted and acknowledged, so + * the relay's only other copy was released while the user is not on the restored screen, and + * the next navigation overwrites both.

+ */ + @EdtTest + public void aRouteRebuildThatThrewKeepsTheStatePendingEvenWhenThePayloadApplied() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + // The form's SHOW is what throws, not the dispatcher: restoreStack() catches a + // dispatcher failure per path and carries on, so a dispatcher that throws just leaves + // nothing to rebuild and returns false -- the orderly case, which is acknowledged on + // purpose. The throw that reaches capture()'s caller comes from show(), which + // restoreStack() deliberately rethrows after undoing the stack and the screen. + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + throw new IllegalStateException("the restored screen could not open"); + } + }); + return f; + } + }); + try { + Map payload = new HashMap(); + payload.put("draft", "half a sentence"); + AppState arriving = new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(160L) + .setTimestamp(System.currentTimeMillis()); + + assertFalse(Continuity.restore(arriving), "a rebuild that threw reported a shown form"); + flushSerialCalls(); + + // The payload DID apply, which is the precondition: this is the combination that + // slipped past the failure branch. + assertNotNull(provider.restored, "the fixture never applied the payload"); + + assertNull(Continuity.readSeenForTest().get("some-other-device"), + "a state whose route rebuild threw was acknowledged durably because its " + + "payload applied, so the relay's only other copy is released while " + + "the user is not on the restored screen"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * An oversized title is refused at setTitle(), not at the next checkpoint. + * + *

capture() builds the AppState through the validating setter, and nothing catches what it + * throws: `dirty` is assigned after capture() returns, so it stayed set and every later + * navigation retried the same failing capture. Nothing was stored or published again, and the + * application was never told why.

+ * + *

The same shape as a local route too long to store, which reaches capture() through the + * navigation stack and cannot be refused at Navigation -- a general routing API must not + * reject a path because continuity could not store it. That one is dropped by the same filter + * the inbound path uses, and the checkpoint still happens.

+ */ + @EdtTest + public void anOversizedTitleIsRefusedAtTheCallAndALongRouteDoesNotStopCheckpoints() { + Continuity.setStateProvider(new RecordingProvider()); + StringBuilder huge = new StringBuilder(); + for (int i = 0; i < 70000; i++) { + huge.append('x'); + } + try { + Continuity.setTitle(huge.toString()); + fail("an oversized title was accepted, so it surfaces from the next checkpoint " + + "instead -- where nothing catches it and every later navigation retries " + + "the same failing capture"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + assertNull(Continuity.getTitle(), "the refused title was stored anyway"); + + // And the route half, which has no earlier place to be refused. + Navigation.setDispatcher(new FakeLongPathDispatcher()); + try { + Navigation.navigate("/" + huge); + Continuity.checkpoint(); + flushSerialCalls(); + + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, + "a local route too long to store ended every checkpoint this process would " + + "make: capture() threw, dirty stayed set, and nothing was written " + + "or published again"); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A disable() before any enable() is still an answer, and an arrival during it is dropped. * @@ -5471,6 +5576,15 @@ private void deliverFromElsewhere(String note, long sequence) { flushSerialCalls(); } + /** A dispatcher that answers any path with a form, however long the path is. */ + static class FakeLongPathDispatcher implements RouteDispatcher { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle("long"); + return f; + } + } + static class RecordingProvider implements StateProvider { final Map saved = new HashMap(); Map restored; From d4d0ecfd4689e9d20c9f4440702311bf1e2e210d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:42:07 +0300 Subject: [PATCH 091/140] Leave the screen a logout callback chose, and refuse an encoding this build cannot read A restored route's show callback that finds the session expired, calls clear(), and shows a login form before returning had that login form replaced: the undo re-showed the screen the restore started from, which is the signed-out account's own UI -- the exact thing the callback put the login form up to avoid. The hard part is that both happen INSIDE Navigation.restoreStack(). By the time it returns, whatever the application did is already current and is indistinguishable from what the restore did: a form that is not the one we started on. The instant the session ended is the one point where they still separate, so the display is sampled at each lifecycle bump. If the restore had already changed the screen by then, anything different showing now was put there afterwards and only the application could have done it; if the session ended before the restore changed anything, what is up came from the restore and the undo is right. One case is still read the wrong way and the code says so: a route FACTORY that ends the session and shows its own form before the restore has installed anything gets that form undone. It is what this code always did, it is much rarer than the show-callback case, and closing it means threading the form restoreStack showed back out of it -- public API this does not need. Separately, an `enc` value other than "1" fell through to "legacy untagged". That is not the unknown-FIELD case, which is ignored on purpose so a newer sender goes on talking to this build: an encoding marker changes how the fields this codec DOES know must be read. Untagged, every encoded scalar reached the provider as a raw string -- "i:5" instead of the number 5 -- and the state was persisted and acknowledged, so the origin's high-water mark advanced and the correctly encoded document was never offered again, not even after the receiving app was upgraded to understand it. A failed read now, which is retryable and leaves the document where a build that understands it can use it. The test asserts both readable encodings still work, so the guard cannot quietly cut off every sender. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 52 ++++++++++- .../com/codename1/continuity/StateCodec.java | 17 ++++ .../continuity/LocalContinuityTest.java | 91 +++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 26dbfaef3c0..8e795314b3e 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -221,6 +221,16 @@ public final class Continuity { /// delivering the previous account's state into the next account's screen. private static int relaySession; + /// What was on screen at the instant the session last ended. + /// + /// The restore undo needs to tell "the restore put this signed-out screen up" from "the + /// application chose this screen when it signed out", and those happen in the same call: + /// clear() is reached from INSIDE Navigation.restoreStack(), through a route factory or a + /// show callback, so by the time restoreStack returns whatever the application did is already + /// current and looks exactly like what the restore did. Sampling the display at the moment + /// the session ended is the one point where the two are still distinguishable. + private static com.codename1.ui.Form formAtSessionEnd; + /// Whether the application has said anything about continuity yet -- either enable() or /// disable(). It is NOT the negation of `enabled`: the two states that share /// `enabled == false` -- nothing said yet, and switched off on purpose -- want opposite @@ -311,6 +321,9 @@ public static void disable() { if (!enabled) { return; } + // Sampled with the bump, not read later: see formAtSessionEnd. + formAtSessionEnd = Display.isInitialized() + ? Display.getInstance().getCurrent() : null; lifecycle++; enabled = false; dirty = false; @@ -1040,7 +1053,8 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // through Navigation, so it records nothing and checkpoints nothing. try { com.codename1.ui.Form now = Display.getInstance().getCurrent(); - if (beforeRestore != null && beforeRestore != now) { //NOPMD CompareObjectsWithEquals + if (beforeRestore != null && beforeRestore != now //NOPMD CompareObjectsWithEquals + && !applicationChoseTheScreen(beforeRestore, now)) { beforeRestore.show(); } } catch (Throwable t) { @@ -1265,6 +1279,9 @@ public void run() { /// One thing it cannot undo: a relay request already on the wire when this is called. Nothing /// in this process can recall that. What this guarantees is that nothing follows it. public static void clear() { + // Sampled with the bump, not read later: see formAtSessionEnd. + formAtSessionEnd = Display.isInitialized() + ? Display.getInstance().getCurrent() : null; lifecycle++; parked = null; dirty = false; @@ -2020,6 +2037,38 @@ public void run() { }); } + /// Whether the screen now showing was picked by the APPLICATION when it ended the session, + /// rather than left there by the restore that is being undone. + /// + /// Both happen inside `Navigation.restoreStack()` -- a route factory or a show callback finds + /// the session expired, calls `clear()`, and may show a login form before returning -- so + /// from out here the two are the same observation: a form that is not the one we started on. + /// Re-showing the previous screen over the application's choice puts the user back on the + /// signed-out account's UI, which is the opposite of what the callback asked for. + /// + /// The instant the session ended is where they separate. If the restore had ALREADY changed + /// the screen by then, anything different showing now was put there afterwards, and only the + /// application could have done that. If the session ended before the restore changed + /// anything, whatever is up came from the restore and the undo is right. + /// + /// One case is still read the wrong way: a route FACTORY that ends the session and shows its + /// own form, before the restore has installed anything. Its screen is then undone. That is + /// what this code always did and is much rarer than the show-callback case above, which is + /// the one being fixed; closing it as well would mean threading the form restoreStack showed + /// back out of it, and that is public API this does not need. + private static boolean applicationChoseTheScreen(com.codename1.ui.Form beforeRestore, + com.codename1.ui.Form now) { + if (formAtSessionEnd == null) { + return false; + } + if (formAtSessionEnd == beforeRestore) { //NOPMD CompareObjectsWithEquals + // The session ended before the restore had put anything up, so what is showing came + // from the restore. + return false; + } + return now != formAtSessionEnd; //NOPMD CompareObjectsWithEquals + } + /// Applies an arrival: offers it to the listeners, then restores or parks it. private static void dispatch(AppState state) { if (isTooOld(state)) { @@ -2868,6 +2917,7 @@ static void reset() { bridgeOverridden = false; enabled = false; applicationHasChosen = false; + formAtSessionEnd = null; autoRestore = true; flushScheduled = false; title = null; diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 1b0a660fde5..dee43f5f464 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -279,6 +279,23 @@ private static void requireKnownTypes(Map m) throws IOException requireRouteStrings(m); requireType(m, KEY_PAYLOAD, Map.class, "an object"); requireType(m, KEY_ENCODING, String.class, "a string"); + Object encoding = m.get(KEY_ENCODING); + if (encoding != null && !ENCODING_TAGGED.equals(encoding)) { + // An unknown ENCODING is not an unknown field. A field this codec does not know is + // ignored on purpose -- that is how the format stays extensible and how a newer + // sender goes on talking to this build. An encoding marker changes how the fields it + // DOES know must be read, so falling back to "untagged" handed the provider every + // encoded scalar as a raw string, and the state was then persisted and acknowledged: + // the origin's high-water mark advanced, so the correctly encoded document was never + // offered again, not even after the receiving app was upgraded to understand it. + // + // A failed read instead, which is retryable and leaves the document on the relay. + throw new IOException("The continuity relay returned a document encoded as \"" + + encoding + "\", which this build cannot read -- it understands \"" + + ENCODING_TAGGED + "\" and documents with no encoding marker at all. " + + "Treated as a failed read so the document stays where a build that " + + "understands it can still use it."); + } requireType(m, KEY_DEVICE, String.class, "a string"); requireType(m, KEY_TITLE, String.class, "a string"); requireNumberLike(m, KEY_SEQUENCE); 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 index 371a94b22ef..94f60b86088 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,97 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A login form the logout callback put up is not replaced by the screen the restore started + * from. + * + *

A restored route's show callback finds the session expired, calls clear(), and shows a + * login form before returning. The undo then re-showed the screen the restore had started + * from, which is the signed-out account's own UI -- the exact thing the callback replaced it + * to avoid. Both happen inside restoreStack(), so from outside they look identical; the + * instant the session ended is where they separate.

+ */ + @EdtTest + public void aScreenTheLogoutCallbackChoseSurvivesTheUndo() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + final Form dashboard = new Form("dashboard"); + dashboard.show(); + flushSerialCalls(); + + final Form login = new Form("login"); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + // The session has expired. Sign out, and put the user where they now + // belong -- which is the whole point of doing this from the callback. + Continuity.clear(); + login.show(); + } + }); + return f; + } + }); + try { + Map payload = new HashMap(); + payload.put("draft", "the previous account's"); + Continuity.restore(new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(170L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertTrue(login == Display.getInstance().getCurrent(), + "the undo put the screen the restore started from back over the login form " + + "the logout callback had just chosen, so the user is returned to " + + "the signed-out account's UI; showing " + + Display.getInstance().getCurrent().getTitle()); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A document in an encoding this build does not know is a failed read, not untagged data. + * + *

An unknown encoding is not an unknown FIELD. A field this codec does not know is ignored + * on purpose -- that is how a newer sender goes on talking to this build. An encoding marker + * changes how the fields it does know must be read, so falling back to "untagged" handed the + * provider every encoded scalar as a raw string, and the state was then persisted and + * acknowledged: the origin's high-water mark advanced, so the correctly encoded document was + * never offered again, not even after the receiving app was upgraded to understand it.

+ */ + @EdtTest + public void aDocumentInAnUnknownEncodingIsAFailedRead() throws Exception { + try { + AppState s = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"," + + "\"enc\":\"2\",\"payload\":{\"n\":\"i:5\"}}"); + fail("a document in encoding \"2\" was read as untagged" + + (s != null ? ", so the provider is handed " + s.getPayload().get("n") + + " where the sender wrote the number 5" : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + + // The two encodings this build DOES understand still work, or the guard would refuse + // every document there is. + AppState tagged = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"," + + "\"enc\":\"1\",\"payload\":{\"n\":\"i:5\"}}"); + assertEquals(Integer.valueOf(5), tagged.getPayload().get("n"), + "a tagged document stopped decoding"); + AppState legacy = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"," + + "\"payload\":{\"n\":\"plain\"}}"); + assertEquals("plain", legacy.getPayload().get("n"), + "a document with no encoding marker was refused, so every older sender is cut off"); + } + /** * A route rebuild that THREW keeps the state pending, even when the payload applied. * From 5b5b6bf528bf71fc634fd2d8521a28ea700ff60d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:06:03 +0300 Subject: [PATCH 092/140] Stop a rebuild whose session ended, commit the routes that were kept, drain a held arrival Three findings, all consequences of fixes earlier on this branch. A route factory that finds the account signed out and calls clear() is the decision the whole lifecycle machinery exists for, and restoreStack() went on invoking every later factory anyway: constructing their forms, and whatever they queried or wrote for that account on the way. The lifecycle check in restore() runs only after restoreStack() returns, so it empties the stack afterwards and undoes none of it. The rebuild now asks between factories, and again before it shows -- the last factory has no next iteration to be stopped by. Navigation asks through a static on Continuity, in the shape routeStackChanged() already uses and for the same reason it gives: the call answers false immediately for every application that does not use continuity and every navigation that is not a restore, and a listener registry here would be public API earned by one internal caller. usableRoutes() filtered only the copy handed to restoreStack() and left the oversized route in the state, so commit() persisted the original and externalize() threw on it every time. The arrival stayed parked -- re-applied on every retry, with every relay publication held behind it -- for ever. The filtered set is what gets committed now. My own fix two commits ago, half applied. And disable() before the first enable() installed no callback, which is the only way to reach an arrival the port is ALREADY holding. iOS parks a cold-launch Handoff before init() and offers it when a callback is next installed, so an app that is logged out at launch, calls disable(), and enables after the login had that activity drained by the enable() -- with `enabled` true again, so it was delivered. The applicationHasChosen flag never got a look in: it is read inside the callback, and no callback existed for the port to offer the arrival to. Installing it from disable() makes the port hand the arrival over while `enabled` is false and the choice is recorded, so the callback claims and drops it -- the same route an arrival that comes later already takes, rather than a second mechanism doing the same job. Both callers now go through one installer that runs once. LocalContinuityBridge has no such retention, so the test carries a bridge that holds an arrival and offers it on setCallback, the way the iOS port does. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 80 ++++++++++- .../src/com/codename1/router/Navigation.java | 30 +++- .../continuity/LocalContinuityTest.java | 136 ++++++++++++++++++ 3 files changed, 239 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 8e795314b3e..6ad40e79631 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -241,6 +241,9 @@ public final class Continuity { /// explicit disable() as though it had never spoken. private static boolean applicationHasChosen; + /// Whether the port already holds this framework's callback. + private static boolean callbackInstalled; + /// The relay session a framework worker is running for, bound for the length of its call /// into the relay and unbound afterwards. Null on the event thread and on any thread the /// application drives itself, which is how mayRelaySend() tells the two apart. @@ -298,13 +301,28 @@ public static void enable() { } enabled = true; applicationHasChosen = true; + installCallback(); + } + + /// Hands the port a callback, once. Installing it is what makes a port offer an arrival it + /// has been holding, so both enable() and disable() do it -- the two answers differ in what + /// the callback then says, not in whether it exists. + /// + /// Only from those two, so a build that merely LINKS this class -- because something else in + /// the framework mentions it -- never installs a callback or touches storage. + private static void installCallback() { + if (callbackInstalled) { + return; + } ContinuityBridge b = bridgeInternal(); - if (b != null) { - try { - b.setCallback(new Callback()); - } catch (Throwable t) { - Log.e(t); - } + if (b == null) { + return; + } + try { + b.setCallback(new Callback()); + callbackInstalled = true; + } catch (Throwable t) { + Log.e(t); } } @@ -319,6 +337,20 @@ public static void disable() { // that came with the login. Saying "no" before saying anything else is still saying it. applicationHasChosen = true; if (!enabled) { + // A callback is installed even though nothing is being turned off, and it is the only + // way to reach an arrival that is already waiting. iOS parks a cold-launch Handoff + // before init() runs and hands it over when a callback is next installed -- so an + // application that is logged out at launch, calls disable(), and enables after the + // login had that parked activity drained by the enable(), when `enabled` is true + // again and the callback delivers it. The applicationHasChosen flag never got a look + // in: it is read inside the callback, and no callback existed for the port to offer + // the arrival to. + // + // Installed here, the port hands it over now, while `enabled` is false and the choice + // is recorded -- so the callback claims and drops it, which is what disable() says + // happens to arriving states. Same route as the one already taken by a state that + // arrives after this returns, rather than a second mechanism doing the same job. + installCallback(); return; } // Sampled with the bump, not read later: see formAtSessionEnd. @@ -552,6 +584,31 @@ public static String getDeviceId() { // Saving // ------------------------------------------------------------------ + /// Internal. Called by `com.codename1.router.Navigation` between the route factories of a + /// restore, and again before it shows the rebuilt screen. True once a factory has ended the + /// session -- so the rebuild stops instead of running every remaining factory against an + /// account that has just signed out. + /// + /// The lifecycle check in `restore()` runs only after `restoreStack()` has returned, which is + /// far too late for this: by then those factories have constructed their forms, and whatever + /// they queried or wrote for the signed-out account is done. Emptying the stack afterwards + /// undoes none of it. + /// + /// A direct call for the reason `routeStackChanged()` gives: it answers false immediately + /// unless a restore is actually in progress, and a listener registry here would be public API + /// earned by one internal caller. + /// + /// #### Returns + /// + /// true when the session that the restore in progress began in has ended + public static boolean restoreSessionEnded() { + return applyingRestore && lifecycle != lifecycleAtRestoreStart; + } + + /// The lifecycle generation the restore in progress began in. Only meaningful while + /// `applyingRestore` is true. + private static int lifecycleAtRestoreStart; + /// Internal. Called by `com.codename1.router.Navigation` after every change to the navigation /// stack; schedules a checkpoint rather than taking one, so a burst of navigations costs a /// single write. @@ -1009,6 +1066,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { boolean shown; boolean routesThrew = false; applyingRestore = true; + lifecycleAtRestoreStart = lifecycleAtRestore; try { shown = Navigation.restoreStack(routes); } catch (Throwable t) { @@ -1063,6 +1121,15 @@ private static boolean restore(final AppState state, boolean[] outFailed) { outFailed[0] = true; return false; } + if (routes.size() != state.getRoutes().size()) { + // The FILTERED set is what gets committed. usableRoutes() dropped a route this device + // cannot store, and only the copy handed to restoreStack() had it removed -- so + // commit() went on to persist the original, externalize() threw on the oversized + // string every time, and the arrival stayed parked: re-applied on every retry, with + // every relay publication held behind it, for ever. Unchecked because these routes + // have already passed the very check that produced this list. + state.setRoutesUnchecked(routes); + } if (routesThrew) { // A THROW, which is a different thing from routes that would not rebuild, and the two // were collapsed here. The reasoning below is about the orderly case: this build no @@ -2917,6 +2984,7 @@ static void reset() { bridgeOverridden = false; enabled = false; applicationHasChosen = false; + callbackInstalled = false; formAtSessionEnd = null; autoRestore = true; flushScheduled = false; diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 2cb1198a799..24ec848c48d 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -297,6 +297,15 @@ public static boolean restoreStack(List paths) { } List rebuilt = new ArrayList(); for (String path : paths) { + if (sessionEnded()) { + // A factory ended the continuity session -- it found the account signed out, + // which is exactly the decision a route factory is entitled to make. Every later + // factory would run for that account: constructing forms, and whatever they query + // or write on the way. The lifecycle check in Continuity.restore() happens after + // this method returns and can only empty the stack afterwards, which undoes none + // of it. + return false; + } if (path == null || path.length() == 0) { continue; } @@ -311,7 +320,10 @@ public static boolean restoreStack(List paths) { rebuilt.add(new NavigationEntry(path, f)); } } - if (rebuilt.isEmpty()) { + if (rebuilt.isEmpty() || sessionEnded()) { + // Asked again, because the loop tests before each factory and the LAST one has no + // "next" iteration to be stopped by. Showing here would put the signed-out account's + // screen in front of the user. return false; } // The PREVIOUS stack is kept until the new one is really on screen. show() runs @@ -360,6 +372,22 @@ public static boolean restoreStack(List paths) { return true; } + /// Whether a continuity restore in progress has had its session ended underneath it. + /// + /// Answers false for every application that does not use continuity, and for every navigation + /// that is not a restore, which is why it can sit in this loop. + private static boolean sessionEnded() { + try { + return com.codename1.continuity.Continuity.restoreSessionEnded(); + } catch (Throwable t) { + // Carrying on is the answer that keeps ordinary navigation working; this guard exists + // for the signed-out case, not to gate the routing API on the continuity framework + // being answerable. + com.codename1.io.Log.e(t); + return false; + } + } + /// Tells the continuity framework that the stack moved, so it can checkpoint. /// /// A direct call rather than a listener: `Continuity.routeStackChanged()` returns immediately 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 index 94f60b86088..62519285cee 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,120 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A factory that ends the session stops the rebuild instead of running the rest. + * + *

Every later factory would run for an account that has just signed out: constructing + * forms, and whatever they query or write on the way. The lifecycle check in restore() runs + * only after restoreStack() has returned, so it can empty the stack afterwards and undoes + * none of that.

+ */ + @EdtTest + public void aFactoryThatEndsTheSessionStopsTheRebuild() { + final List built = new ArrayList(); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + built.add(url); + if ("/orders".equals(url)) { + // The account is signed out. This is the decision a route factory is + // entitled to make, and the reason the lifecycle machinery exists. + Continuity.clear(); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders", "/orders/17", "/orders/17/pay")) + .setDeviceId("some-other-device") + .setSequence(190L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertEquals(1, built.size(), + "the rebuild ran " + built + " -- every factory after the one that signed out " + + "was invoked for that account, and emptying the stack afterwards " + + "undoes none of what they did"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A route this device cannot store is dropped from what gets COMMITTED too, not only from + * what gets rebuilt. + * + *

usableRoutes() filtered the copy handed to restoreStack() and left the oversized route in + * the state, so commit() went on to persist the original and externalize() threw on it every + * time. The arrival stayed parked -- re-applied on every retry, with every relay publication + * held behind it -- for ever.

+ */ + @EdtTest + public void aRouteTooLongToStoreIsDroppedFromWhatIsCommitted() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Navigation.setDispatcher(new FakeLongPathDispatcher()); + try { + StringBuilder huge = new StringBuilder("/"); + for (int i = 0; i < 70000; i++) { + huge.append('x'); + } + Map payload = new HashMap(); + payload.put("draft", "worth keeping"); + AppState arriving = new AppState() + .setPayload(payload) + .setDeviceId("some-other-device") + .setSequence(191L) + .setTimestamp(System.currentTimeMillis()); + // Unchecked, because a remote document is accepted unchecked on purpose -- which is + // exactly how an unstorable route reaches this device in the first place. + arriving.setRoutesUnchecked(java.util.Arrays.asList("/orders", huge.toString())); + + Continuity.restore(arriving); + flushSerialCalls(); + + assertNotNull(Continuity.readSeenForTest().get("some-other-device"), + "the arrival was never acknowledged, so commit() failed on the route that " + + "usableRoutes() had already dropped: it stays parked, is re-applied " + + "on every retry, and holds every relay publication behind it"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A cold-launch arrival the port is already holding is dropped by the first disable(). + * + *

iOS parks a Handoff before init() runs and hands it over when a callback is next + * installed. An application that is logged out at launch, calls disable(), and enables after + * the login had that parked activity drained by the enable() -- when `enabled` is true again, + * so the callback delivered it. The choice flag never got a look in: it is read inside the + * callback, and no callback existed for the port to offer the arrival to.

+ */ + @EdtTest + public void aParkedArrivalIsDroppedByAFirstDisable() { + HoldingBridge holding = new HoldingBridge(); + holding.pending = StateCodec.toMap(fromElsewhere("parked before init()", 180L)); + Continuity.setBridge(holding); + + // Never enabled: logged out at launch, and saying so. + Continuity.disable(); + assertNull(holding.pending, + "the port is still holding the arrival, so nothing has asked it for one and the " + + "enable() after the login will drain it"); + + Continuity.enable(); + flushSerialCalls(); + assertNull(Continuity.getRestorableState(), + "the arrival the port was holding through an explicit disable() was restored by " + + "the enable() that followed it"); + } + /** * A login form the logout callback put up is not replaced by the screen the restore started * from. @@ -5676,6 +5790,28 @@ public Form dispatch(String url) { } } + /** A bridge that behaves the way the iOS port does: it holds an activity that arrived before + * anything was listening, and offers it the moment a callback is installed, letting go only + * if the callback claims it. LocalContinuityBridge has no such retention, so the case cannot + * be reached through it. */ + static class HoldingBridge extends LocalContinuityBridge { + Map pending; + + @Override + public void setCallback(ContinuityCallback c) { + super.setCallback(c); + if (c == null || pending == null) { + return; + } + Map offered = pending; + if (c.continuationReceived(Continuity.getActivityType(), offered)) { + // Claimed, so the port lets go of it. Declined, and it stays for the next + // callback -- which is the whole behaviour being relied on. + pending = null; + } + } + } + static class RecordingProvider implements StateProvider { final Map saved = new HashMap(); Map restored; From 89753cc517f5e55a4674e20025648102c1f8c024 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:30:36 +0300 Subject: [PATCH 093/140] Close the route-factory case in the undo, and enforce the per-key iCloud limit The first is the residual my own comment named two commits ago, and the commit before this one turned it from rare into the main factory case: once the rebuild aborts as soon as a factory ends the session, the restore installs nothing at all, so a factory that signs out and opens its own login form leaves formAtSessionEnd equal to the screen we started on -- and the undo replaced the login form with the signed-out account's screen. Sampling the display cannot separate those two, because both happen before the restore has shown anything. What settles it is whether the restore showed anything AT ALL. restoreStack() returns true only after its own show() has succeeded, so false means nothing of the restore's is on display: it aborted, found nothing to rebuild, or had show() throw and already undid its own screen. With nothing of its own up there is nothing for the undo to take down, and anything showing that is not what we started on was put there by the application. So `shown` gates the undo, and the helper is left for the case it does settle -- a show callback that signs out after the restored form is already installed, where the display at the moment the session ended is genuinely the restored form and does separate the two. Its comment no longer claims a case it gets wrong, because it no longer has one. The iCloud preflight enforced the two TOTALS and not the per-key maximum, so a long key sailed through a nearly empty store, went to setString:forKey:, and left put() reporting a cross-device write it cannot deliver -- which the local readback cannot see, and which is exactly why the preflight exists. Apple publishes 64 UTF-8 bytes for a key alongside the 1 MB and 1024-key figures this check already used. That half is verified by inspection and a clang syntax check against a translated project's headers, not by a test: the native store is not reachable from core-unittests, and the surrounding preflight it extends has no test either. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 21 ++++++--- Ports/iOSPort/nativeSources/IOSNative.m | 10 +++- .../continuity/LocalContinuityTest.java | 47 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6ad40e79631..12f9ec1ee65 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1111,7 +1111,17 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // through Navigation, so it records nothing and checkpoints nothing. try { com.codename1.ui.Form now = Display.getInstance().getCurrent(); - if (beforeRestore != null && beforeRestore != now //NOPMD CompareObjectsWithEquals + // `shown` FIRST, and it closes the case the helper below used to get wrong. + // The restore only ever put a screen up when restoreStack() returned true; false + // means it installed nothing -- it aborted because a factory ended the session, + // found nothing to rebuild, or had show() throw and undid its own screen already. + // With nothing of the restore's on display there is nothing to take down, so + // anything showing that is not what we started on was put there by the + // application: a route factory that signed out and opened its own login form is + // the case, and it used to have that form replaced by the signed-out account's + // screen. + if (shown && beforeRestore != null + && beforeRestore != now //NOPMD CompareObjectsWithEquals && !applicationChoseTheScreen(beforeRestore, now)) { beforeRestore.show(); } @@ -2118,11 +2128,10 @@ public void run() { /// application could have done that. If the session ended before the restore changed /// anything, whatever is up came from the restore and the undo is right. /// - /// One case is still read the wrong way: a route FACTORY that ends the session and shows its - /// own form, before the restore has installed anything. Its screen is then undone. That is - /// what this code always did and is much rarer than the show-callback case above, which is - /// the one being fixed; closing it as well would mean threading the form restoreStack showed - /// back out of it, and that is public API this does not need. + /// Only asked when the restore actually SHOWED something -- see the caller. A route factory + /// that ends the session and opens its own form does so before anything is installed, and + /// that case is settled there rather than here: with nothing of the restore's on display, + /// there is nothing to take down. private static boolean applicationChoseTheScreen(com.codename1.ui.Form beforeRestore, com.codename1.ui.Form now) { if (formAtSessionEnd == null) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 6da84f3329e..c5d9cc6c891 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20849,9 +20849,15 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la bytes += cn1ContinuityValueBytes([held objectForKey:existing]); } count++; - bytes += [k lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + NSUInteger keyBytes = [k lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + bytes += keyBytes; bytes += [v lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; - if (count > 1024 || bytes > 1048576) { + // The PER-KEY maximum as well as the totals. Apple publishes 64 UTF-8 bytes for a key + // alongside the 1 MB and 1024-key figures, and only the two totals were being checked -- so + // a long key sailed through a nearly empty store, went to setString:forKey:, and left put() + // reporting a cross-device write it cannot deliver. The local readback below cannot see the + // difference, which is precisely why this check exists. + if (count > 1024 || bytes > 1048576 || keyBytes > 64) { // Refused rather than written. A value the store keeps and never propagates is the one // outcome an application cannot detect for itself, and it is exactly when it needs its // own fallback. 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 index 62519285cee..bc9a8c0b639 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,53 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A login form a route FACTORY opened survives the undo. + * + *

The other half of the show-callback case, and the one the undo used to get wrong. The + * factory finds the account signed out, calls clear(), and opens its own login form -- all + * before the restore has installed anything, so sampling the display at the moment the + * session ended cannot separate the two. What settles it is that the restore never showed + * anything at all: with nothing of its own on display, there is nothing for the undo to take + * down.

+ */ + @EdtTest + public void aScreenARouteFactoryChoseSurvivesTheUndo() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Form dashboard = new Form("dashboard"); + dashboard.show(); + flushSerialCalls(); + + final Form login = new Form("login"); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Continuity.clear(); + login.show(); + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(200L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertTrue(login == Display.getInstance().getCurrent(), + "the undo put the screen the restore started from back over the login form " + + "the route factory had just chosen, so the user is returned to the " + + "signed-out account's UI; showing " + + Display.getInstance().getCurrent().getTitle()); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A factory that ends the session stops the rebuild instead of running the rest. * From a136cc4307db3adf784cd52bc7130225716d2e1b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:51:46 +0300 Subject: [PATCH 094/140] Refuse an empty route string, and make the payload view unmodifiable all the way down An empty string is a string, so it passed every type check the route array has. The state is therefore not empty and is not read as a tombstone -- and then restoreStack() skips the path, rebuilds nothing, and the arrival is classified as an attempt that failed: parked for ever, re-offered on every launch, with every relay publication held behind it. Refused rather than filtered, for the reason the sibling refusal beside it already gives: dropping the only route turns the document into an empty state, which means the sending device cleared its work -- something else entirely. Nothing this framework writes produces one, because setRoutes() skips empty paths, so no legitimate sender is refused. getPayload() wrapped the outer map and left every nested List and Map mutable. That matters most for an arrival, because the same AppState handed to a listener or a provider is afterwards parked, persisted, acknowledged and published: a provider that consumed a nested list -- removing items as it applied them, which is an ordinary way to write that loop -- rewrote the framework's own snapshot of what arrived. setPayload() deep-copies on the way in for exactly this reason and the way out did not match. The wrapping is not free, so framework code that only reads the payload uses a package-private accessor for the raw map instead: the callers inside this package are not the ones the wrapping protects against. The one that hands the payload to the application keeps the wrapped view, which is the whole point. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 55 +++++++++++- .../com/codename1/continuity/Continuity.java | 6 +- .../com/codename1/continuity/StateCodec.java | 23 ++++- .../continuity/LocalContinuityTest.java | 85 +++++++++++++++++++ 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java index 049f729a0d7..aa853507117 100644 --- a/CodenameOne/src/com/codename1/continuity/AppState.java +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -112,11 +112,64 @@ public AppState setRoutes(List r) { /// The application payload. Never null, possibly empty. /// + /// The view is unmodifiable ALL THE WAY DOWN. Wrapping only the outer map left every nested + /// List and Map mutable, which matters most for an arrival: the same AppState handed to a + /// listener or a provider is afterwards parked, persisted, acknowledged and published, so a + /// caller that consumed a nested list -- removing items as it applied them, which is an + /// ordinary way to write that loop -- changed the framework's own snapshot of what arrived. + /// setPayload() deep-copies on the way in for exactly this reason; the way out needed to + /// match. + /// /// #### Returns /// /// an unmodifiable view of the payload public Map getPayload() { - return Collections.unmodifiableMap(payload); + return unmodifiableValues(payload); + } + + /// The payload itself, for framework code that only reads it. Wrapping is not free and the + /// callers inside this package are not the ones the wrapping protects against. + Map payloadRef() { + return payload; + } + + /// `value`, with every List and Map inside it wrapped as unmodifiable. + private static Object unmodifiableValue(Object value) { + if (value instanceof List) { + List in = (List) value; + List out = new ArrayList(); + for (Object element : in) { + out.add(unmodifiableValue(element)); + } + return Collections.unmodifiableList(out); + } + if (value instanceof Map) { + // Never a cast to Map: on the iOS virtual machine a failed cast does + // not throw, so the guarded instanceof above is the only portable way to ask, and the + // wildcard read below is what the compiler can actually check. + return unmodifiableValues(castValues((Map) value)); + } + // Everything else a payload may hold -- String, Integer, Long, Double, Boolean -- is + // immutable already. + return value; + } + + private static Map unmodifiableValues(Map in) { + Map out = new HashMap(); + for (Map.Entry e : in.entrySet()) { + out.put(e.getKey(), unmodifiableValue(e.getValue())); + } + return Collections.unmodifiableMap(out); + } + + private static Map castValues(Map in) { + Map out = new HashMap(); + for (Map.Entry e : in.entrySet()) { + if (e.getKey() instanceof String) { + out.put((String) e.getKey(), e.getValue()); + } + } + return out; } /// Replaces the application payload. diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 12f9ec1ee65..b9e54560a14 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -681,8 +681,8 @@ public static void checkpoint() { // Carrying forward gives up neither -- the checkpoint keeps the newest routes and the // newest payload that ever read cleanly. AppState previous = readStored(); - if (previous != null && !previous.getPayload().isEmpty()) { - state.setPayloadUnchecked(previous.getPayload()); + if (previous != null && !previous.payloadRef().isEmpty()) { + state.setPayloadUnchecked(previous.payloadRef()); } } // Owed while anything about this capture was not durable, so a later suspend retries it. @@ -1008,7 +1008,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { p.restoreState(state.getPayload()); // An empty payload is not an application. It is what a route-only state carries, // and counting it would make the question below answer yes for every state. - applied = !state.getPayload().isEmpty(); + applied = !state.payloadRef().isEmpty(); } catch (Throwable t) { Log.e(t); failed = true; diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index dee43f5f464..2d26c240bc5 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -89,7 +89,7 @@ private StateCodec() { 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_PAYLOAD, encode(state.payloadRef())); m.put(KEY_ENCODING, ENCODING_TAGGED); m.put(KEY_DEVICE, state.getDeviceId()); if (state.getTitle() != null) { @@ -315,8 +315,25 @@ private static void requireRouteStrings(Map m) throws IOExceptio } List list = (List) routes; for (int i = 0; i < list.size(); i++) { - if (list.get(i) instanceof String) { - continue; + Object path = list.get(i); + if (path instanceof String) { + if (((String) path).length() > 0) { + continue; + } + // An EMPTY string is a string, and it is not a route. It survives every check + // above, so the state is not empty and is not read as a tombstone -- and then + // restoreStack() skips the path, rebuilds nothing, and the arrival is classified + // as an attempt that failed: parked for ever, re-offered on every launch, with + // every relay publication held behind it. + // + // Refused rather than filtered, for the reason the refusal below gives: dropping + // the only route turns the document into an empty state, which means something + // else entirely. Nothing this framework writes produces one -- setRoutes() skips + // empty paths -- so no legitimate sender is refused. + throw new IOException("The continuity relay returned a document whose route at " + + "index " + i + " is an empty string. It is not a path that can be " + + "rebuilt, and keeping it would leave an arrival that can never be " + + "applied and never be let go of."); } throw new IOException("The continuity relay returned a document whose route at index " + i + " is not a string. Dropping it would leave a state with fewer routes " 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 index bc9a8c0b639..81eb4fd5d15 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,91 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * An empty route string is a failed read, not an arrival that can never be applied. + * + *

It is a string, so it passes every type check, and the state is therefore not empty and + * not read as a tombstone. Then restoreStack() skips the path, rebuilds nothing, and the + * arrival is classified as an attempt that failed: parked for ever, re-offered on every + * launch, with every relay publication held behind it.

+ * + *

Refused rather than filtered, because dropping the only route turns the document into an + * empty state -- which means the sending device cleared its work, something else entirely. + * Nothing this framework writes produces one: setRoutes() skips empty paths.

+ */ + @EdtTest + public void anEmptyRouteStringIsAFailedRead() throws Exception { + try { + AppState s = StateCodec.fromJson("{\"device\":\"d\",\"seq\":\"1\",\"routes\":[\"\"]}"); + fail("an empty route string was accepted" + + (s != null && !s.isEmpty() + ? " -- and the state is NOT empty, so it is not a tombstone either: " + + "it can never be applied and is never let go of" + : "")); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + + // A real route alongside one is refused too: the sender meant two screens, and quietly + // rebuilding one of them is not the same state. + try { + StateCodec.fromJson("{\"device\":\"d\",\"seq\":\"1\",\"routes\":[\"/a\",\"\"]}"); + fail("an empty route beside a real one was accepted"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + } + + /** + * A nested collection in a delivered payload cannot be mutated by the application. + * + *

The outer map was unmodifiable and everything inside it was not. That matters most for + * an arrival: the same AppState handed to a listener or a provider is afterwards parked, + * persisted, acknowledged and published, so a provider that consumed a nested list -- removing + * items as it applied them, which is an ordinary way to write that loop -- changed the + * framework's own snapshot of what arrived. setPayload() deep-copies on the way in for + * exactly this reason; the way out had to match.

+ */ + @EdtTest + public void aNestedPayloadCollectionCannotBeMutatedByTheApplication() { + List items = new ArrayList(); + items.add("first"); + items.add("second"); + Map nested = new HashMap(); + nested.put("deeper", "value"); + Map payload = new HashMap(); + payload.put("items", items); + payload.put("nested", nested); + + AppState state = new AppState().setPayload(payload) + .setDeviceId("some-other-device").setSequence(210L) + .setTimestamp(System.currentTimeMillis()); + + Object handed = state.getPayload().get("items"); + assertTrue(handed instanceof List, "the payload did not survive as a list"); + try { + ((List) handed).remove(0); + fail("a nested list in the delivered payload was mutable, so an application that " + + "consumes its items as it applies them rewrites the framework's snapshot -- " + + "which is then parked, persisted, acknowledged and published"); + } catch (UnsupportedOperationException expected) { + // What an unmodifiable view is for. + } + + Object deep = state.getPayload().get("nested"); + assertTrue(deep instanceof Map, "the payload did not survive as a map"); + try { + ((Map) deep).put("deeper", "changed"); + fail("a nested map in the delivered payload was mutable"); + } catch (UnsupportedOperationException expected) { + // As above. + } + + // And the state itself is untouched, which is the point of all of it. + assertEquals(2, ((List) state.getPayload().get("items")).size(), + "the framework's snapshot changed"); + } + /** * A login form a route FACTORY opened survives the undo. * From b0e42b24f34f883681cc32b3347c3a09bc0e7250 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:13:29 +0300 Subject: [PATCH 095/140] Refuse a state held across a logout, and let a standalone relay send Returning false to keep an arrival while prompting the user is documented behaviour, and the object the application hands back to restore() later carried nothing about the session it came from. A clear() while the prompt was up -- and a login for another account after it -- still restored the previous account's payload, routes and checkpoint. clear() cannot reach into the application to take the object away, so it is refused on the way back in. The generation is stamped on the state by dispatch(), the one place a state is handed to the application, and it is not part of the wire form or the stored form: it describes a session of this process, not the state, and a state that outlived the session it arrived in is exactly what it exists to catch. One the application BUILT, or read back from storage, carries -1 and is unaffected -- this is about the framework's own delivery outliving its session, not about restricting what an application may ask for. The second is a comment of mine that was not true of its own code. mayRelaySend() falls back to the identity check when no worker session is bound, and its comment said a relay the application drives itself "gets the identity answer it always got" -- but that answer is FALSE for a relay that was never installed, so a RestStateRelay used on its own had every publish() and fetch() throw before issuing a request. It is a public class with a public constructor. The unbound case now refuses only when a DIFFERENT relay is installed, which is the confusion the check is actually for: an object kept across a setRelay() and used afterwards, sending one account's state under the next account's credentials. A relay the framework was never given has no session to confuse. The test pins both halves, so the allowance cannot quietly become no check at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 18 ++++ .../com/codename1/continuity/Continuity.java | 33 +++++++- .../continuity/LocalContinuityTest.java | 83 +++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java index aa853507117..524dd3e4b85 100644 --- a/CodenameOne/src/com/codename1/continuity/AppState.java +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -66,6 +66,14 @@ public final class AppState implements Externalizable { private List routes = new ArrayList(); private Map payload = new HashMap(); + /// The lifecycle generation this state was DELIVERED in, or -1 for one the application + /// built or read back from storage. + /// + /// Not part of the wire form and not part of the stored form: it describes a session of this + /// process, not the state, and a state that outlived the session it arrived in is exactly + /// what it exists to catch. + private int deliveredGeneration = -1; + private String deviceId = ""; private String title; private long sequence; @@ -110,6 +118,16 @@ public AppState setRoutes(List r) { return this; } + /// The generation this state was delivered in, or -1 when this framework never delivered it. + int deliveredGeneration() { + return deliveredGeneration; + } + + /// Stamped by dispatch(), the one place a state is handed to the application. + void deliveredGeneration(int generation) { + deliveredGeneration = generation; + } + /// The application payload. Never null, possibly empty. /// /// The view is unmodifiable ALL THE WAY DOWN. Wrapping only the outer map left every nested diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index b9e54560a14..4f70d59d1d8 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -986,6 +986,21 @@ private static boolean restore(final AppState state, boolean[] outFailed) { if (state == null) { return false; } + if (state.deliveredGeneration() >= 0 && state.deliveredGeneration() != lifecycle) { + // Delivered in a session that has since ended. The only way to be holding one of + // these is the documented prompt-then-restore pattern -- a listener returns false, + // keeps the state, and calls back when the user accepts -- and clear() cannot reach + // into the application to take it away. So it is refused here instead, which is the + // same promise every other path already keeps. + // + // A state the application BUILT, or one read back from storage, carries -1 and is + // unaffected: this is about the framework's own delivery outliving its session, not + // about restricting what an application may ask for. + Log.p("Continuity: refusing a state that was delivered before the session ended. " + + "Continuity.clear() or disable() ran while it was being held."); + outFailed[0] = false; + return false; + } // Whether any part of this state actually reached the application. Nothing is written // or acknowledged until something has: a route-only state naming routes this build no // longer registers applies nothing at all, and replacing the stored checkpoint with it @@ -2174,6 +2189,12 @@ private static void dispatch(AppState state) { // regardless: it restored and PERSISTED the signed-out account's state after a logout had // just deleted it, or re-parked it into a session that had been emptied. int lifecycleAtDispatch = lifecycle; + // Stamped on the STATE as well, because a listener may keep it. Returning false to hold + // an arrival while it prompts the user is documented behaviour, and the object the + // application hands back to restore() later carries nothing about the session it came + // from -- so a clear() while the prompt was up, and a login for another account after + // it, still restored the previous account's payload, routes and checkpoint. + state.deliveredGeneration(lifecycleAtDispatch); // A copy, because a listener that reacts by unregistering itself is ordinary and would // otherwise mutate the list being walked. List snapshot = new ArrayList(listeners); @@ -2514,7 +2535,17 @@ static boolean mayRelaySend(final StateRelay r) { } Integer bound = RELAY_CALL_SESSION.get(); if (bound == null) { - return isInstalledRelay(r); + // Not a framework worker: the application is driving this relay itself. Refused only + // when a DIFFERENT relay is installed, which is the confusion this guards against -- + // an object kept across a setRelay() and used afterwards, sending the previous + // account's state under the next account's credentials. + // + // isInstalledRelay() alone answered false for a relay that was never installed at + // all, so a RestStateRelay used on its own -- a public class with a public + // constructor -- had every publish() and fetch() throw before issuing a request. The + // comment here claimed that case worked; it did not, and there is no session for it + // to confuse. + return relay == null || r == relay; //NOPMD CompareObjectsWithEquals } final int session = bound.intValue(); final boolean[] live = new boolean[1]; 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 index 81eb4fd5d15..a3ac4a71af9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,89 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * A state a listener was holding is refused once the session it arrived in has ended. + * + *

Returning false to keep an arrival while prompting the user is documented behaviour, and + * the object handed back to restore() later carried nothing about the session it came from. + * A clear() while the prompt was up -- and a login for another account after it -- still + * restored the previous account's payload, routes and checkpoint. clear() cannot reach into + * the application to take the object away, so it is refused on the way back in.

+ */ + @EdtTest + public void aStateHeldAcrossALogoutIsRefusedWhenItComesBack() { + final AppState[] held = new AppState[1]; + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + // "Keep it, I will prompt and call restore() when the user accepts." + held[0] = state; + return false; + } + }); + + Continuity.deliver(fromElsewhere("the previous account's work", 220L)); + flushSerialCalls(); + assertNotNull(held[0], "the listener never got the arrival, so this test is about nothing"); + + // The user signs out while the prompt is up, and signs in as somebody else. + Continuity.clear(); + Continuity.enable(); + Continuity.setStateProvider(provider); + provider.restored = null; + + // ... and only then taps "continue". + assertFalse(Continuity.restore(held[0]), + "a state from the previous session reported a restored screen"); + flushSerialCalls(); + + assertNull(provider.restored, + "the previous account's payload was restored into the next account's session, " + + "after a clear() that promises nothing from before it survives"); + } + + /** + * A RestStateRelay the application drives itself is allowed to send. + * + *

The guard is about an object kept across a setRelay() and used afterwards, which would + * send one account's state under another account's credentials. A relay that was never + * installed has no session to confuse -- and asking only "is this the installed relay" made + * every publish() and fetch() on a standalone instance throw before issuing a request, for a + * public class with a public constructor.

+ */ + @EdtTest + public void aRelayTheApplicationDrivesItselfMaySend() { + // Nothing installed at all, which is how a standalone relay is used. + StateRelay standalone = new StateRelay() { + public void publish(AppState state) { + } + + public AppState fetch() { + return null; + } + }; + assertTrue(Continuity.mayRelaySend(standalone), + "a relay the framework was never given was refused, so an application using " + + "RestStateRelay on its own cannot send anything"); + + // And the case the guard is actually for still refuses. + StateRelay installed = new StateRelay() { + public void publish(AppState state) { + } + + public AppState fetch() { + return null; + } + }; + Continuity.setRelay(installed); + assertFalse(Continuity.mayRelaySend(standalone), + "a relay that is not the installed one was allowed to send, so an object kept " + + "across a setRelay() can still send the previous account's state"); + assertTrue(Continuity.mayRelaySend(installed), "the installed relay was refused"); + } + /** * An empty route string is a failed read, not an arrival that can never be applied. * From 5b9d0e547183365e9d674f0d6518a82acc5d02d9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:35:23 +0300 Subject: [PATCH 096/140] Check an arriving payload like a local one, and keep key case in the simulated store The first reverses a decision this branch had tested on purpose, so the reasons matter. anArrivingPayloadIsNotRevalidated asserted that a payload from another device goes in unchecked, because it was validated where it was produced and because refusing it here would turn a remote build's mistake into an exception on this device. The second half stopped being true when fromMap() began answering null for a bad field type: a refusal is a failed READ, so the document stays on the relay for a build that can use it and nothing is thrown at the user. The first half does not survive either. Accepting an unrepresentable value does not make it work -- it reaches the listeners and the provider, is acknowledged, and then throws out of externalize() when the checkpoint is written, which is the failure that leaves `dirty` set and retries for ever. A null nested in a list is worse: it survives to the iOS property-list sanitizer, which drops it and shifts every index after it, so the application's data quietly changes shape between one device and the next -- and check() already says exactly that, in the code the inbound path was skipping. So the inbound payload now goes through the same check as a local one, answering null on failure. The old test is rewritten rather than deleted, and carries why its own reasoning no longer holds, so the next reader does not simply revert it. Separately, the simulated store escapes uppercase letters into its filenames. The default filesystems on macOS and Windows fold case, so "Theme" and "theme" resolved to one file: the second put() overwrote the first while the index listed both, both reads answered with one value, and removing either removed both. The store being simulated is case-sensitive, and a simulation that merges two keys is worse than no simulation -- it looks like it works. The first version of that test asserted nothing: the unit-test storage is case-sensitive, so it passed against the unfixed code. It now runs through a storage that folds case, the way the filesystem does, and fails without the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 22 ++++-- .../continuity/LocalContinuityBridge.java | 11 ++- .../continuity/AppStateWireTest.java | 48 ++++++++++--- .../continuity/LocalContinuityTest.java | 71 +++++++++++++++++++ 4 files changed, 139 insertions(+), 13 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 2d26c240bc5..809b7388675 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -180,10 +180,24 @@ public static AppState fromMap(Map m) { tagged ? decode(entry.getValue()) : 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); + // Validated, with the SAME check a locally built payload gets -- there is one + // definition of what a payload may hold and this is it. AppState documents the + // restriction and setPayload() enforced it, while a payload arriving from another + // device went in unchecked: a null nested in a list survived to the listeners and the + // provider, was acknowledged, and then met the iOS property-list sanitizer, which + // drops it and shifts every index after it. The application's data changes shape + // between one device and the next, silently. + // + // NULL rather than an exception, which is the same answer the field-type checks above + // give and the reason the old comment here gave for skipping this: a remote mistake + // must not become an exception on this device. It does not -- it becomes a failed + // read, so the document stays on the relay for a build that can use it. + try { + state.setPayload(copy); + } catch (IllegalArgumentException malformed) { + Log.e(malformed); + return null; + } } Object device = m.get(KEY_DEVICE); if (device instanceof String) { diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 86f79cfdbf5..8e2f89694af 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -250,7 +250,16 @@ private static String storageName(String key) { StringBuilder sb = new StringBuilder(PREFIX); for (int i = 0; i < key.length(); i++) { char c = key.charAt(i); - if (c == '/' || c == '\\' || c == '%' || c == '?' || c == '*' || c == ':' + // Uppercase letters are escaped along with the characters a path cannot carry, + // because the DEFAULT filesystems on macOS and Windows are case-insensitive: "Theme" + // and "theme" resolved to one file, so the second put() overwrote the first while the + // index listed both keys, both reads answered with one value, and removing either + // removed both. The store being simulated is case-sensitive, and a simulation that + // merges two keys is worse than no simulation -- it looks like it works. + if (c >= 'A' && c <= 'Z') { + sb.append('$'); + sb.append(Integer.toHexString(c).toUpperCase()); + } else if (c == '/' || c == '\\' || c == '%' || c == '?' || c == '*' || c == ':' || c == '=' || c == '$') { sb.append('$'); String hex = Integer.toHexString(c).toUpperCase(); 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 index bffc38510db..effcf4cccd9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -338,24 +338,56 @@ public void execute() { } /** - * A payload arriving from another device is NOT validated. + * A payload arriving from another device gets the SAME check a local one gets, and a failure + * is a failed read rather than an exception. * - *

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.

+ *

This test used to assert the opposite, on the reasoning that the payload was validated + * where it was produced and that refusing it here would turn a remote build's mistake into an + * exception on this device. The second half stopped being true when fromMap() began answering + * null for a bad field type: a refusal is a failed READ, so the document stays on the relay + * for a build that can use it and nothing is thrown at the user.

+ * + *

The first half does not survive either. Accepting an unrepresentable value does not make + * it work -- it reaches the listeners and the provider, is acknowledged, and then throws out + * of externalize() when the checkpoint is written, which is the failure that leaves `dirty` + * set and retries for ever. A null nested in a list is worse than that: it survives to the + * iOS property-list sanitizer, which drops it and shifts every index after it, so the + * application's data quietly changes shape between one device and the next.

*/ @Test - public void anArrivingPayloadIsNotRevalidated() { + public void anArrivingPayloadIsCheckedLikeALocalOne() { 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); + assertNull(StateCodec.fromMap(wire), + "an unrepresentable value arrived intact, so it reaches the application and then " + + "breaks the checkpoint that tries to store it"); + + // A null nested in a LIST, which is the shape the iOS sanitizer silently reindexes. + Map nulled = new HashMap(); + Map withList = new HashMap(); + withList.put("items", Arrays.asList("a", null, "b")); + nulled.put("payload", withList); + nulled.put("device", "other"); + assertNull(StateCodec.fromMap(nulled), + "a null list element arrived intact, so the list is one shorter on an iPad than " + + "on the device that sent it"); + + // What a conforming sender writes still goes through, or this guard would refuse every + // arrival there is. + Map fine = new HashMap(); + Map goodPayload = new HashMap(); + goodPayload.put("items", Arrays.asList("a", "b")); + goodPayload.put("n", Integer.valueOf(3)); + fine.put("payload", goodPayload); + fine.put("device", "other"); + AppState back = StateCodec.fromMap(fine); + assertNotNull(back, "a conforming payload was refused"); assertEquals("other", back.getDeviceId()); + assertEquals(Integer.valueOf(3), back.getPayload().get("n")); } @Test 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 index a3ac4a71af9..3413f0be7fb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3655,6 +3655,43 @@ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() { "an enabled framework refused its own activity type"); } + /** + * Two keys differing only in case are two keys in the simulated store. + * + *

The default filesystems on macOS and Windows are case-insensitive, so "Theme" and + * "theme" resolved to one file: the second put() overwrote the first while the index listed + * both, both reads answered with one value, and removing either removed both. The store being + * simulated is case-sensitive, and a simulation that merges two keys is worse than no + * simulation -- it looks like it works.

+ */ + @EdtTest + public void twoKeysDifferingOnlyInCaseAreTwoKeys() { + // Through a storage that FOLDS CASE, which is what the default macOS and Windows + // filesystems do and what this test is about. The unit-test storage is case-sensitive, so + // a first version of this test passed against the unfixed code: it asserted nothing. + Storage real = Storage.getInstance(); + Storage.setStorageInstance(new CaseFoldingStorage(real)); + LocalContinuityBridge b = new LocalContinuityBridge(); + try { + assertTrue(b.syncedStorePut("Theme", "upper"), "the fixture could not write a value"); + assertTrue(b.syncedStorePut("theme", "lower"), "the fixture could not write a value"); + + assertEquals("upper", b.syncedStoreGet("Theme"), + "the lowercase write overwrote the uppercase key's value, so two distinct " + + "keys share one file"); + assertEquals("lower", b.syncedStoreGet("theme"), "the lowercase value did not survive"); + + b.syncedStoreRemove("theme"); + assertEquals("upper", b.syncedStoreGet("Theme"), + "removing one key removed the other as well"); + assertNull(b.syncedStoreGet("theme"), "the removed key still reads back"); + } finally { + b.syncedStoreRemove("Theme"); + b.syncedStoreRemove("theme"); + Storage.setStorageInstance(real); + } + } + /** * A state a listener was holding is refused once the session it arrived in has ended. * @@ -5283,6 +5320,40 @@ public void deleteStorageFile(String name) { } } + /** Storage that folds every name to lower case before delegating, the way the default + * filesystems on macOS and Windows resolve paths. */ + static class CaseFoldingStorage extends Storage { + private final Storage delegate; + + CaseFoldingStorage(Storage delegate) { + this.delegate = delegate; + } + + @Override + public boolean writeObject(String name, Object o) { + return delegate.writeObject(fold(name), o); + } + + @Override + public Object readObject(String name) { + return delegate.readObject(fold(name)); + } + + @Override + public boolean exists(String name) { + return delegate.exists(fold(name)); + } + + @Override + public void deleteStorageFile(String name) { + delegate.deleteStorageFile(fold(name)); + } + + private static String fold(String name) { + return name == null ? null : name.toLowerCase(); + } + } + static class RefusingOneStorage extends Storage { private final Storage delegate; private final String refused; From 1717b7d1d6774f94504b5b3cc1a6856beb5c770b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:01:34 +0300 Subject: [PATCH 097/140] Bind the arrival's first hop to its session, and refuse a stale acknowledgement deliver() is the one method here called from a foreign thread, and the state it queues had no generation on it. A logout already sitting on the event queue means admit() runs after clear() and reads the NEW generation, so every later check passes and the previous account's state is restored and persisted after the logout that promises nothing from before it survives. The second-turn dispatch carried a generation; this first hop carried none. The generation is read where the arrival happens, on the platform's thread. An int read from another thread yields a value the event thread wrote at some point and never a future one, so the comparison can be stale-old but never stale-new: the worst it does is refuse an arrival that raced the logout exactly, which is the answer that side of the race wants anyway. No lock and no volatile -- the same shape, and the same one-directional argument, as the `enabled` read the callback already makes from that thread. acknowledge() is the other door onto the hold-it-and-come-back-later pattern and restore() was the only one that had been closed. Marking a state from an ended session recreates a durable high-water mark for the signed-out account, so a state the NEXT account sends from that same device with a lower sequence is discarded as already handled -- and the stale acknowledgement would release a parked state belonging to an origin this session never heard from. Both doors now ask the same question through one helper, which is what makes it obvious there are exactly two. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 41 ++++++++++- .../continuity/LocalContinuityTest.java | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 4f70d59d1d8..278cc676d9e 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -957,6 +957,14 @@ public static void acknowledge(AppState state) { if (state == null) { return; } + if (isFromAnEndedSession(state)) { + // The same hold-it-and-come-back-later pattern restore() already refuses, arriving + // through the other door. Marking it would recreate a durable high-water mark for the + // signed-out account, so a state the NEXT account sends from that same device with a + // lower sequence is discarded as already handled -- and the stale acknowledgement + // would release a parked state belonging to an origin this session never heard from. + return; + } noteActedOn(state); } @@ -976,6 +984,20 @@ public static boolean restore(final AppState state) { return restore(state, new boolean[1]); } + /// Whether this state was DELIVERED in a session that has since ended. + /// + /// The only way an application is holding one is the documented prompt-then-come-back + /// pattern -- a listener returns false, keeps the state, and calls restore() or + /// acknowledge() when the user answers -- and clear() cannot reach into the application to + /// take the object away. So both doors ask. + /// + /// A state the application BUILT, or one read back from storage, carries -1 and is + /// unaffected: this is about the framework's own delivery outliving its session, not about + /// restricting what an application may ask for. + private static boolean isFromAnEndedSession(AppState state) { + return state.deliveredGeneration() >= 0 && state.deliveredGeneration() != lifecycle; + } + /// As above, also reporting whether the attempt FAILED as opposed to having nothing to do. /// /// The public boolean answers "is a form showing", which is what a caller needs to decide @@ -986,7 +1008,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { if (state == null) { return false; } - if (state.deliveredGeneration() >= 0 && state.deliveredGeneration() != lifecycle) { + if (isFromAnEndedSession(state)) { // Delivered in a session that has since ended. The only way to be holding one of // these is the documented prompt-then-restore pattern -- a listener returns false, // keeps the state, and calls back when the user accepts -- and clear() cannot reach @@ -1991,12 +2013,29 @@ static void deliver(final AppState state) { parked = state; return; } + // The generation the arrival BELONGS to, read here rather than on the event thread, + // because here is where the arrival happens. A logout already queued ahead of this means + // admit() runs after clear() and reads the NEW generation, so every later check passes + // and the previous account's state is restored and persisted after the logout that + // promised nothing from before it survives. The second-turn dispatch had a generation and + // this first hop had none. + // + // Read from the platform's thread, which this method is documented to run on. An int read + // from another thread yields a value the event thread wrote at some point and never a + // future one, so the comparison can be stale-old but never stale-new: the worst it does + // is refuse an arrival that raced the logout exactly, which is the answer that side of + // the race wants anyway. + final int arrivedIn = lifecycle; // ALWAYS queued, even when the caller is already on the EDT. Admission and dispatch are // deliberately separate turns -- see admit() -- and running one caller's arrival inline // while another's is queued would put them in different orders depending on who called. Display.getInstance().callSerially(new Runnable() { @Override public void run() { + if (arrivedIn != lifecycle) { + // clear() or disable() ran between the arrival and this turn. + return; + } admit(state); } }); 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 index 3413f0be7fb..b909cc21fd5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,78 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * An arrival queued behind a logout is not admitted into the session that follows it. + * + *

deliver() marshals from the platform's thread, so an arrival that a port handed over + * before the logout can find clear() already queued ahead of it. admit() then ran after + * clear(), read the NEW generation, and every later check passed: the previous account's + * state was restored and persisted after the logout that promises nothing from before it + * survives. The second-turn dispatch carried a generation and this first hop carried none. + */ + @EdtTest + public void anArrivalQueuedBehindALogoutIsNotAdmitted() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + + // The order the bug needs: the arrival is handed over FIRST -- deliver() queues admit() + // -- and the logout is queued behind it... no: the logout must run BEFORE admit() does, + // which is what happens when the port delivers while a logout is already on the queue. + // Queued here in that order, then drained together. + Display.getInstance().callSerially(new Runnable() { + public void run() { + Continuity.clear(); + } + }); + Continuity.deliver(fromElsewhere("the previous account's work", 230L)); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "a state that arrived before the logout was admitted into the session after it, " + + "and restored the previous account's work"); + assertNull(Continuity.readSeenForTest().get("some-other-device"), + "it was marked durably too, so the origin's real states are refused as already " + + "seen after a restart"); + } + + /** + * An acknowledgement from a session that has ended is refused, like a restore from one. + * + *

The same hold-it-and-come-back-later pattern arriving through the other door. Marking it + * recreates a durable high-water mark for the signed-out account, so a state the NEXT account + * sends from that same device with a lower sequence is discarded as already handled.

+ */ + @EdtTest + public void anAcknowledgementFromAnEndedSessionIsRefused() { + final AppState[] held = new AppState[1]; + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + held[0] = state; + return false; + } + }); + + Continuity.deliver(fromElsewhere("the previous account's work", 240L)); + flushSerialCalls(); + assertNotNull(held[0], "the listener never got the arrival, so this test is about nothing"); + + Continuity.clear(); + Continuity.enable(); + + // The prompt finishes late, after the logout. + Continuity.acknowledge(held[0]); + flushSerialCalls(); + + assertNull(Continuity.readSeenForTest().get("some-other-device"), + "the signed-out account's sequence became this session's durable high-water mark, " + + "so a lower-numbered state the NEXT account sends from that same device " + + "is discarded as already handled"); + } + /** * A state a listener was holding is refused once the session it arrived in has ended. * From 3c1813faac88e942688f87972df83e49f3367bdf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:22:54 +0300 Subject: [PATCH 098/140] Let a throwing route factory fail the restore, and escape what Windows normalises Swallowing a factory's exception made it the same thing as a route this build no longer registers. The failed screen was skipped, an EARLIER one was shown, and restoreStack() reported success -- so Continuity.restore() persisted and acknowledged a partial state, the relay stopped offering it, and the user was left on the wrong screen with nothing left to retry from. An unregistered route answers null and is still skipped, which is the tolerance that was wanted: it will not start working on the next launch either. A throw is the transient breakage -- a dependency not up yet on a cold launch -- and the same distinction restore() already draws between the two. The method already propagates a throw from show() for this reason, so this is one contract, not a new one, and it is documented on restoreStack(). The simulated store escapes '.' and ' ' as well. Windows normalises a trailing dot or space away, so "theme" and "theme." resolved to one value file while the index listed both: both reads answered with the last write, and removing either removed the other's value. Escaped everywhere rather than only at the end, because "a. b" and "a.b " would otherwise need the rule applied twice to see that they differ. '<', '>', '"' and '|' go with them, and they are not aliases -- Windows refuses them outright, so a key holding one worked on macOS and failed on Windows. The store being simulated accepts any string, and the simulation should not be the thing that decides which keys an application may use. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/LocalContinuityBridge.java | 14 +- .../src/com/codename1/router/Navigation.java | 22 +++- .../continuity/LocalContinuityTest.java | 124 ++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 8e2f89694af..4d88393de14 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -260,7 +260,19 @@ private static String storageName(String key) { sb.append('$'); sb.append(Integer.toHexString(c).toUpperCase()); } else if (c == '/' || c == '\\' || c == '%' || c == '?' || c == '*' || c == ':' - || c == '=' || c == '$') { + || c == '=' || c == '$' + // '.' and ' ' because Windows NORMALIZES them away at the end of a name, so + // "theme" and "theme." resolved to one file: the index listed both keys, both + // reads answered with the last write, and removing either removed the other's + // value. Escaped everywhere rather than only at the end, because "a. b" and + // "a.b " would otherwise need the rule applied twice to see they differ. + // + // '<', '>', '"' and '|' are not aliases -- Windows refuses them outright -- + // so a key holding one worked on macOS and failed on Windows. The store being + // simulated accepts any string, and the simulation should not be the thing + // that decides which keys an application may use. + || c == '.' || c == ' ' + || c == '<' || c == '>' || c == '"' || c == '|') { sb.append('$'); String hex = Integer.toHexString(c).toUpperCase(); if (hex.length() < 2) { diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 24ec848c48d..2f3e3cd1f9d 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -290,6 +290,9 @@ public static boolean popTo(NavigationEntry entry) { /// #### Returns /// /// true when at least one frame was rebuilt and shown + /// A route factory that THROWS propagates, rather than being skipped: it is a failure the + /// caller can retry, not a route this build has stopped registering. A factory that answers + /// null is still skipped. public static boolean restoreStack(List paths) { RouteDispatcher d = dispatcher; if (d == null || paths == null || paths.isEmpty()) { @@ -309,13 +312,18 @@ public static boolean restoreStack(List 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; - } + // NOT caught. A factory that THROWS is a different thing from a route this build no + // longer registers, and swallowing it made them the same: the failed screen was + // skipped, an earlier one was shown, and this method returned success -- so + // Continuity.restore() persisted and acknowledged a partial state, the relay stopped + // offering it, and the user was left on the wrong screen with no copy to retry from. + // + // An unregistered route answers null and is still skipped, which is the tolerance + // that was wanted: it will not start working on the next launch either. A throw is + // the transient breakage -- a dependency not up yet on a cold launch -- and letting + // it out is what makes the restore retryable. The method already propagates a throw + // from show() for the same reason. + Form f = d.dispatch(path); if (f != null) { rebuilt.add(new NavigationEntry(path, f)); } 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 index b909cc21fd5..1bbed22c4c0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,89 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * A route factory that throws fails the whole restore rather than skipping one screen. + * + *

Skipping it made a throwing factory the same as a route this build no longer registers: + * the failed screen was passed over, an EARLIER one was shown, and restoreStack() reported + * success -- so the state was persisted and acknowledged, the relay stopped offering it, and + * the user was left on the wrong screen with no copy left to retry from.

+ * + *

An unregistered route answers null and is still skipped, which is the tolerance that was + * wanted: it will not start working on the next launch either.

+ */ + @EdtTest + public void aRouteFactoryThatThrowsFailsTheRestore() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + if ("/orders/17".equals(url)) { + // The screen the user was actually on, and its dependency is not up yet. + throw new IllegalStateException("the order screen could not be built"); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Map payload = new HashMap(); + payload.put("draft", "worth keeping"); + AppState arriving = new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/orders", "/orders/17")) + .setDeviceId("some-other-device") + .setSequence(250L) + .setTimestamp(System.currentTimeMillis()); + + assertFalse(Continuity.restore(arriving), + "the restore reported a shown form although the screen the user was on could " + + "not be built"); + flushSerialCalls(); + + assertNull(Continuity.readSeenForTest().get("some-other-device"), + "the partial restore was acknowledged durably, so the relay stops offering " + + "the state and there is nothing left to retry from -- while the " + + "user sits on a screen they did not ask for"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A key ending in a dot is not the same key as one without it. + * + *

Windows normalises a trailing dot or space away, so "theme" and "theme." resolved to one + * value file while the index listed both: both reads answered with the last write, and + * removing either removed the other's value. The store being simulated is doing none of that. + * The sibling test covers case folding; this is the same class of collision through a + * different rule.

+ */ + @EdtTest + public void aKeyEndingInADotIsNotTheSameKey() { + Storage real = Storage.getInstance(); + Storage.setStorageInstance(new SuffixTrimmingStorage(real)); + LocalContinuityBridge b = new LocalContinuityBridge(); + try { + assertTrue(b.syncedStorePut("theme", "plain"), "the fixture could not write a value"); + assertTrue(b.syncedStorePut("theme.", "dotted"), "the fixture could not write a value"); + + assertEquals("plain", b.syncedStoreGet("theme"), + "the dotted key overwrote the plain one, so two distinct keys share one file"); + assertEquals("dotted", b.syncedStoreGet("theme."), "the dotted value did not survive"); + + b.syncedStoreRemove("theme."); + assertEquals("plain", b.syncedStoreGet("theme"), + "removing one key removed the other as well"); + } finally { + b.syncedStoreRemove("theme"); + b.syncedStoreRemove("theme."); + Storage.setStorageInstance(real); + } + } + /** * An arrival queued behind a logout is not admitted into the session that follows it. * @@ -5426,6 +5509,47 @@ private static String fold(String name) { } } + /** Storage that trims trailing dots and spaces from every name, the way Windows normalises + * a filename. */ + static class SuffixTrimmingStorage extends Storage { + private final Storage delegate; + + SuffixTrimmingStorage(Storage delegate) { + this.delegate = delegate; + } + + @Override + public boolean writeObject(String name, Object o) { + return delegate.writeObject(trim(name), o); + } + + @Override + public Object readObject(String name) { + return delegate.readObject(trim(name)); + } + + @Override + public boolean exists(String name) { + return delegate.exists(trim(name)); + } + + @Override + public void deleteStorageFile(String name) { + delegate.deleteStorageFile(trim(name)); + } + + private static String trim(String name) { + if (name == null) { + return null; + } + int end = name.length(); + while (end > 0 && (name.charAt(end - 1) == '.' || name.charAt(end - 1) == ' ')) { + end--; + } + return name.substring(0, end); + } + } + static class RefusingOneStorage extends Storage { private final Storage delegate; private final String refused; From bf5f57fbdb1c923cf0cccb8ae471a77acecf4a51 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:44:13 +0300 Subject: [PATCH 099/140] Escape store names by whitelist, and drop an inbound title too long to store The character list in storageName() grew by one entry per review round and was wrong every time in a new way: uppercase letters, because macOS and Windows fold case; a trailing dot or space, because Windows trims them; '<', '>', '"' and '|', because Windows refuses them; and now canonically equivalent Unicode, because a decomposed and a precomposed accent can name one file. Every one of those merged two distinct keys into a single value while the index went on listing both -- both reads answering with the last write, and removing either removing the other's value. There was no reason to believe the list was finally complete, so it is gone. Everything outside [a-z0-9_-] is escaped, which makes the name pure ASCII out of characters no filesystem rewrites and stops the simulation depending on which folding, normalisation or reserved-name rules the host happens to apply. The mapping stays injective because '$' is itself escaped. Escaping can multiply a key's length by five and a filesystem will not take a name of any length, so a long name is truncated and made unique again by a hash of the whole key. The test drives a storage that folds case, trims trailing dots and spaces AND normalises accents, all at once -- the previous two tests each covered one rule, and each would have passed against the others' bugs. Separately, an inbound title longer than a stored checkpoint can hold is dropped and the state around it kept. Carrying it was the worst of the three answers: commit() persists it through externalize(), which throws on the oversized string every time, so the arrival is parked, re-applied on every retry and holds every relay publication behind it -- after the provider and the route rebuild have already run. Refusing the whole document would cost the user their work over a label a receiving device may show, which is why this one field is dropped where a bad route is refused. Nothing this framework sends produces one: setTitle() refuses it at the call. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 17 ++- .../continuity/LocalContinuityBridge.java | 91 ++++++++----- .../continuity/LocalContinuityTest.java | 126 ++++++++++++++++++ 3 files changed, 198 insertions(+), 36 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 809b7388675..bcd6f99f3a4 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -205,7 +205,22 @@ public static AppState fromMap(Map m) { } Object title = m.get(KEY_TITLE); if (title instanceof String) { - state.setTitleUnchecked((String) title); + String label = (String) title; + if (exceedsWritableLength(label)) { + // DROPPED, and only this field. A title is the label a receiving device may show + // before the user accepts -- losing it costs a nicety, while refusing the + // document costs the user their work. Carrying it was the worst of the three: + // commit() persists it through externalize(), which throws on the oversized + // string every time, so the arrival is parked, re-applied on every retry and + // holds every relay publication behind it -- after the provider and the route + // rebuild have already run. + // + // Nothing this framework sends produces one: setTitle() refuses it at the call. + Log.p("Continuity: dropping a continuation title longer than " + + MAX_STRING_BYTES + " bytes. The state itself is kept."); + } else { + state.setTitleUnchecked(label); + } } state.setSequence(asLong(m.get(KEY_SEQUENCE))); state.setTimestamp(asLong(m.get(KEY_TIMESTAMP))); diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 4d88393de14..591d341de8c 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -242,48 +242,69 @@ public boolean syncedStorePut(String key, String value) { /// the other. That arrived with the move off Preferences -- which has no such rule -- so it /// is a defect this class introduced while fixing a different one, not an old one. /// - /// Every character Storage would rewrite is escaped as `$` and two hex digits, and `$` itself - /// with it, which makes the mapping reversible and therefore collision-free: two different - /// keys cannot produce one name. The keys themselves are unrestricted, exactly as the - /// platform store leaves them. + /// The storage name for one application key. + /// + /// A WHITELIST, not a list of characters to avoid. Everything outside `[a-z0-9_-]` becomes + /// `$` and four hex digits, so the name is pure ASCII made of characters no filesystem + /// rewrites -- and the simulation stops depending on which folding, normalisation or + /// reserved-name rules the host happens to apply. + /// + /// The list it replaces grew by one entry per review round and was wrong every time in a new + /// way: uppercase letters, because macOS and Windows fold case; a trailing dot or space, + /// because Windows trims them; `<`, `>`, `"` and `|`, because Windows refuses them; and then + /// canonically equivalent Unicode, because a decomposed and a precomposed accent can name one + /// file. Every one of those merged two distinct keys into a single value while the index went + /// on listing both -- both reads answering with the last write, and removing either removing + /// the other's value. A simulation that merges two keys is worse than no simulation: it looks + /// like it works. There is no reason to believe that list was finally complete, and with a + /// whitelist the question does not arise. + /// + /// The mapping is injective, which is what makes it collision-free: `$` is itself escaped, so + /// an escape group can never be produced by literal characters. + /// + /// PREFIX is left alone. It is a constant, identical in every name, so nothing about it can + /// distinguish one key from another -- and INDEX still cannot be reached from here, for the + /// reason its own comment gives. private static String storageName(String key) { StringBuilder sb = new StringBuilder(PREFIX); for (int i = 0; i < key.length(); i++) { char c = key.charAt(i); - // Uppercase letters are escaped along with the characters a path cannot carry, - // because the DEFAULT filesystems on macOS and Windows are case-insensitive: "Theme" - // and "theme" resolved to one file, so the second put() overwrote the first while the - // index listed both keys, both reads answered with one value, and removing either - // removed both. The store being simulated is case-sensitive, and a simulation that - // merges two keys is worse than no simulation -- it looks like it works. - if (c >= 'A' && c <= 'Z') { - sb.append('$'); - sb.append(Integer.toHexString(c).toUpperCase()); - } else if (c == '/' || c == '\\' || c == '%' || c == '?' || c == '*' || c == ':' - || c == '=' || c == '$' - // '.' and ' ' because Windows NORMALIZES them away at the end of a name, so - // "theme" and "theme." resolved to one file: the index listed both keys, both - // reads answered with the last write, and removing either removed the other's - // value. Escaped everywhere rather than only at the end, because "a. b" and - // "a.b " would otherwise need the rule applied twice to see they differ. - // - // '<', '>', '"' and '|' are not aliases -- Windows refuses them outright -- - // so a key holding one worked on macOS and failed on Windows. The store being - // simulated accepts any string, and the simulation should not be the thing - // that decides which keys an application may use. - || c == '.' || c == ' ' - || c == '<' || c == '>' || c == '"' || c == '|') { - sb.append('$'); - String hex = Integer.toHexString(c).toUpperCase(); - if (hex.length() < 2) { - sb.append('0'); - } - sb.append(hex); - } else { + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { sb.append(c); + continue; + } + sb.append('$'); + String hex = Integer.toHexString(c).toUpperCase(); + for (int pad = hex.length(); pad < 4; pad++) { + sb.append('0'); } + sb.append(hex); } - return sb.toString(); + if (sb.length() <= MAX_NAME_CHARS) { + return sb.toString(); + } + // Escaping can multiply a key's length by five, and a filesystem will not take a name of + // any length. Truncated and then made unique again by a hash of the WHOLE key, so two + // long keys sharing a prefix still address different files. + return sb.substring(0, MAX_NAME_CHARS) + "$$" + hash(key); + } + + /// The most characters a storage name may use before it is truncated and hashed. Well inside + /// what every filesystem this simulation runs on accepts. + private static final int MAX_NAME_CHARS = 120; + + /// FNV-1a, 64 bit, as 16 hex digits. Only ever used to keep two truncated names apart. + private static String hash(String key) { + long h = 0xcbf29ce484222325L; + for (int i = 0; i < key.length(); i++) { + h ^= key.charAt(i); + h *= 0x100000001b3L; + } + StringBuilder out = new StringBuilder(Long.toHexString(h).toUpperCase()); + while (out.length() < 16) { + out.insert(0, '0'); + } + return out.toString(); } /// Writes one value, reporting whether it actually reached storage. 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 index 1bbed22c4c0..f86cbf5e972 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,78 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * Keys the host filesystem would merge stay distinct, whatever rule it merges them by. + * + *

The character list this replaces grew by one entry per review round and was wrong every + * time in a new way: case folding, a trimmed trailing dot, characters Windows refuses, and + * canonically equivalent Unicode. Each merged two distinct keys into one value file while the + * index listed both. The whitelist makes every name pure ASCII out of characters no + * filesystem rewrites, so the question is closed rather than answered once more.

+ */ + @EdtTest + public void keysTheFilesystemWouldMergeStayDistinct() { + // A storage that folds case, trims trailing dots and spaces, AND normalises accents -- + // every rule a host has been observed to apply, at once. Nothing here should reach it in + // a form it can change. + Storage real = Storage.getInstance(); + Storage.setStorageInstance(new HostileNamingStorage(real)); + LocalContinuityBridge b = new LocalContinuityBridge(); + String[] keys = {"theme", "Theme", "theme.", "theme ", "caf\u00e9", "cafe\u0301"}; + try { + for (int i = 0; i < keys.length; i++) { + assertTrue(b.syncedStorePut(keys[i], "value" + i), + "the fixture could not write " + keys[i]); + } + for (int i = 0; i < keys.length; i++) { + assertEquals("value" + i, b.syncedStoreGet(keys[i]), + "key " + i + " reads back another key's value, so two distinct keys share " + + "one file while the index lists both"); + } + // And removing one leaves the rest alone. + b.syncedStoreRemove(keys[0]); + assertNull(b.syncedStoreGet(keys[0]), "the removed key still reads back"); + for (int i = 1; i < keys.length; i++) { + assertEquals("value" + i, b.syncedStoreGet(keys[i]), + "removing one key removed key " + i + " as well"); + } + } finally { + for (int i = 0; i < keys.length; i++) { + b.syncedStoreRemove(keys[i]); + } + Storage.setStorageInstance(real); + } + } + + /** + * An inbound title too long to store is dropped, and the state it came with is kept. + * + *

Carrying it was the worst of the three answers: commit() persists it through + * externalize(), which throws on the oversized string every time, so the arrival is parked, + * re-applied on every retry and holds every relay publication behind it -- after the provider + * and the route rebuild have already run. Refusing the whole document would cost the user + * their work over a label a receiving device may show.

+ */ + @EdtTest + public void anInboundTitleTooLongToStoreIsDroppedAndTheStateKept() throws Exception { + StringBuilder huge = new StringBuilder(); + for (int i = 0; i < 70000; i++) { + huge.append('x'); + } + Map wire = new HashMap(); + wire.put("device", "some-other-device"); + wire.put("seq", "260"); + wire.put("routes", java.util.Arrays.asList("/orders")); + wire.put("title", huge.toString()); + + AppState back = StateCodec.fromMap(wire); + assertNotNull(back, "the whole document was refused over a label"); + assertNull(back.getTitle(), "the oversized title was carried, so the checkpoint that " + + "tries to store it throws on every retry for ever"); + assertEquals(java.util.Arrays.asList("/orders"), back.getRoutes(), + "the user's actual work did not survive"); + } + /** * A route factory that throws fails the whole restore rather than skipping one screen. * @@ -5550,6 +5622,60 @@ private static String trim(String name) { } } + /** Storage that applies every name-mangling rule a host filesystem has been seen to apply: + * case folding, trailing dot and space trimming, and Unicode normalisation. */ + static class HostileNamingStorage extends Storage { + private final Storage delegate; + + HostileNamingStorage(Storage delegate) { + this.delegate = delegate; + } + + @Override + public boolean writeObject(String name, Object o) { + return delegate.writeObject(mangle(name), o); + } + + @Override + public Object readObject(String name) { + return delegate.readObject(mangle(name)); + } + + @Override + public boolean exists(String name) { + return delegate.exists(mangle(name)); + } + + @Override + public void deleteStorageFile(String name) { + delegate.deleteStorageFile(mangle(name)); + } + + private static String mangle(String name) { + if (name == null) { + return null; + } + String out = name.toLowerCase(); + int end = out.length(); + while (end > 0 && (out.charAt(end - 1) == '.' || out.charAt(end - 1) == ' ')) { + end--; + } + out = out.substring(0, end); + // Composed and decomposed accents become one name, the way a normalising filesystem + // resolves them. + StringBuilder folded = new StringBuilder(); + for (int i = 0; i < out.length(); i++) { + char c = out.charAt(i); + if (c == '\u00e9') { + folded.append("e\u0301"); + } else { + folded.append(c); + } + } + return folded.toString(); + } + } + static class RefusingOneStorage extends Storage { private final Storage delegate; private final String refused; From 62f53930e4902d2a2f6b37fafec6a04aca46436b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:13:17 +0300 Subject: [PATCH 100/140] Fix the CLDC11 break, install one callback per bridge, keep untagged booleans The first is mine and the Ant leg caught it, which is the only build that could: Long.toHexString(long) and StringBuilder.substring(int,int) are in NEITHER vm/JavaAPI nor Ports/CLDC11, and Maven compiles core against the full JDK and accepts both. javase-simulator-tests went red on the previous commit. The hash formats each 32-bit half with Integer.toHexString and the truncation goes through String.substring, which are in both. I had the rule and did not run the check. It costs about twenty seconds: ant -q -f Ports/CLDC11/build.xml jar ant -q -f CodenameOne/build.xml compile That build is green locally now, and the note about running it before every push that touches CodenameOne/src is in my memory rather than only in this message. installSyncedStoreCallback() called setCallback() directly, so every SyncedStore.addChangeListener() gave the same bridge another one -- ContinuityBridge documents that setCallback is called once, and a port that registers a native observer there keeps all of them and delivers every store change as many times as there are listeners. refreshBridge() had the same unconditional call. Both go through the single installer now, and its guard is the bridge INSTANCE rather than a flag, because one field has to answer two questions: a second install on the same bridge is the bug, while a bridge the port has SWAPPED must be given one -- which is the whole job of refreshBridge(). The test pins both halves, so the guard cannot quietly turn refreshBridge() inert. And the JSON parser answers a raw true or false with the strings "true" and "false" unless told otherwise. Harmless for the tagged form this codec writes, where "b:true" is a string either way, and wrong for an untagged compatibility document from a hand-written endpoint: the payload reached the listeners and the provider with Strings where the sender wrote booleans, passed validation because a String is a representable type, and was acknowledged. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 50 +++++------ .../com/codename1/continuity/StateCodec.java | 7 ++ .../continuity/LocalContinuityBridge.java | 21 ++++- .../continuity/LocalContinuityTest.java | 86 +++++++++++++++++++ 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 278cc676d9e..c7b0507008d 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -241,8 +241,15 @@ public final class Continuity { /// explicit disable() as though it had never spoken. private static boolean applicationHasChosen; - /// Whether the port already holds this framework's callback. - private static boolean callbackInstalled; + /// The bridge this framework's callback was last given to, or null. + /// + /// The INSTANCE rather than a boolean, because both questions have to be answered by one + /// field: a second install on the same bridge is the bug -- ContinuityBridge documents that + /// setCallback is called once, and a port that registers a native observer there ends up + /// with several, delivering every store change as many times as there are listeners -- while + /// a bridge the port has SWAPPED must be given one, which is the whole job of + /// refreshBridge(). + private static ContinuityBridge callbackInstalledOn; /// The relay session a framework worker is running for, bound for the length of its call /// into the relay and unbound afterwards. Null on the event thread and on any thread the @@ -311,16 +318,16 @@ public static void enable() { /// Only from those two, so a build that merely LINKS this class -- because something else in /// the framework mentions it -- never installs a callback or touches storage. private static void installCallback() { - if (callbackInstalled) { - return; - } ContinuityBridge b = bridgeInternal(); if (b == null) { return; } + if (b == callbackInstalledOn) { //NOPMD CompareObjectsWithEquals + return; + } try { b.setCallback(new Callback()); - callbackInstalled = true; + callbackInstalledOn = b; } catch (Throwable t) { Log.e(t); } @@ -2996,15 +3003,12 @@ public static ContinuityBridge bridgeForSyncedStore() { /// which is what lets the listener work with continuity still off. public static void installSyncedStoreCallback() { storeCallbackInstalled = true; - ContinuityBridge b = bridgeInternal(); - if (b == null) { - return; - } - try { - b.setCallback(new Callback()); - } catch (Throwable t) { - Log.e(t); - } + // Through the one installer, which is what stops this being called once per LISTENER. + // Every SyncedStore.addChangeListener() reached here, so a second listener gave the same + // bridge a second callback -- and ContinuityBridge documents that setCallback is called + // once, so a port that registers a native observer there keeps both and delivers every + // store change twice. + installCallback(); } /// Internal. Re-installs the framework's inbound seam on whatever bridge the port now @@ -3021,15 +3025,11 @@ public static void refreshBridge() { if (!enabled && !storeCallbackInstalled) { return; } - ContinuityBridge b = bridgeInternal(); - if (b == null) { - return; - } - try { - b.setCallback(new Callback()); - } catch (Throwable t) { - Log.e(t); - } + // Also through the one installer. It re-installs exactly when the bridge is a DIFFERENT + // object, which is what this method is for and is a sharper test than the unconditional + // call it replaces: a port that calls this without having swapped anything no longer + // stacks a second callback on the bridge it already gave one to. + installCallback(); } static ContinuityBridge bridgeInternal() { @@ -3063,7 +3063,7 @@ static void reset() { bridgeOverridden = false; enabled = false; applicationHasChosen = false; - callbackInstalled = false; + callbackInstalledOn = null; formAtSessionEnd = null; autoRestore = true; flushScheduled = false; diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index bcd6f99f3a4..25e5e2fbb6b 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -282,6 +282,13 @@ public static AppState fromJson(String json) throws IOException { // distinguishable from one that was never sent. JSONParser parser = new JSONParser(); parser.setIncludeNullsInstance(true); + // Booleans as BOOLEANS. The parser defaults to answering a raw JSON true or false with + // the strings "true" and "false", which is fine for the tagged form this codec writes -- + // "b:true" is a string either way -- and wrong for an untagged compatibility document + // from a hand-written endpoint: the payload reached the listeners and the provider with + // Strings where the sender wrote booleans, passed validation because a String is a + // representable type, and was acknowledged. + parser.setUseBooleanInstance(true); Map parsed = parser.parseJSON(new java.io.StringReader(json)); requireKnownTypes(parsed); return fromMap(parsed); diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 591d341de8c..8043264aae0 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -286,7 +286,7 @@ private static String storageName(String key) { // Escaping can multiply a key's length by five, and a filesystem will not take a name of // any length. Truncated and then made unique again by a hash of the WHOLE key, so two // long keys sharing a prefix still address different files. - return sb.substring(0, MAX_NAME_CHARS) + "$$" + hash(key); + return sb.toString().substring(0, MAX_NAME_CHARS) + "$$" + hash(key); } /// The most characters a storage name may use before it is truncated and hashed. Well inside @@ -294,16 +294,29 @@ private static String storageName(String key) { private static final int MAX_NAME_CHARS = 120; /// FNV-1a, 64 bit, as 16 hex digits. Only ever used to keep two truncated names apart. + /// + /// Formatted through Integer.toHexString on each half rather than Long.toHexString, and + /// String.substring rather than StringBuilder.substring, because core is compiled a second + /// time against Ports/CLDC11 and translated against vm/JavaAPI -- neither of which defines + /// those two. The Maven build compiles against the full JDK and accepts them, so the mistake + /// only appears in the Ant leg. private static String hash(String key) { long h = 0xcbf29ce484222325L; for (int i = 0; i < key.length(); i++) { h ^= key.charAt(i); h *= 0x100000001b3L; } - StringBuilder out = new StringBuilder(Long.toHexString(h).toUpperCase()); - while (out.length() < 16) { - out.insert(0, '0'); + return hex32((int) (h >>> 32)) + hex32((int) h); + } + + /// One 32-bit half as exactly eight uppercase hex digits. + private static String hex32(int value) { + String hex = Integer.toHexString(value).toUpperCase(); + StringBuilder out = new StringBuilder(); + for (int pad = hex.length(); pad < 8; pad++) { + out.append('0'); } + out.append(hex); return out.toString(); } 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 index f86cbf5e972..1477dac1d62 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,79 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * A bridge is given the framework's callback once, however many listeners register. + * + *

ContinuityBridge documents that setCallback is called once, and every + * SyncedStore.addChangeListener() reached the install path -- so a second listener gave the + * same bridge a second callback, and a port that registers a native observer there keeps both + * and delivers every store change twice.

+ * + *

A bridge the port has SWAPPED must still be given one, which is what refreshBridge() + * exists for, so the guard is the bridge INSTANCE rather than a flag.

+ */ + @EdtTest + public void aBridgeIsGivenTheCallbackOnceHoweverManyListenersRegister() { + CountingBridge counting = new CountingBridge(); + Continuity.setBridge(counting); + SyncedStoreListener first = new SyncedStoreListener() { + public void storeChanged() { + } + }; + SyncedStoreListener second = new SyncedStoreListener() { + public void storeChanged() { + } + }; + try { + SyncedStore.addChangeListener(first); + SyncedStore.addChangeListener(second); + Continuity.enable(); + Continuity.refreshBridge(); + + assertEquals(1, counting.callbacks, + "the bridge was given " + counting.callbacks + " callbacks, so a port that " + + "registers a native observer in setCallback keeps every one of them " + + "and delivers each store change that many times"); + + // And a bridge the port SWAPS in still gets one, or refreshBridge() would be inert. + CountingBridge replacement = new CountingBridge(); + Continuity.setBridge(replacement); + Continuity.refreshBridge(); + assertEquals(1, replacement.callbacks, + "a swapped-in bridge was left with no callback, so every inbound " + + "continuation and store notification goes nowhere"); + } finally { + SyncedStore.removeChangeListener(first); + SyncedStore.removeChangeListener(second); + } + } + + /** + * A raw JSON boolean in an untagged document stays a boolean. + * + *

The parser defaults to answering true and false with the strings "true" and "false". + * That is harmless for the tagged form this codec writes -- "b:true" is a string either way -- + * and wrong for an untagged compatibility document from a hand-written endpoint: the payload + * reached the listeners and the provider with Strings where the sender wrote booleans, passed + * validation because a String is a representable type, and was acknowledged.

+ */ + @EdtTest + public void anUntaggedJsonBooleanStaysABoolean() throws Exception { + AppState back = StateCodec.fromJson( + "{\"device\":\"other\",\"seq\":\"10\",\"payload\":{\"on\":true,\"off\":false}}"); + assertNotNull(back, "the document was refused"); + assertEquals(Boolean.TRUE, back.getPayload().get("on"), + "a raw JSON true reached the application as " + back.getPayload().get("on")); + assertEquals(Boolean.FALSE, back.getPayload().get("off"), + "a raw JSON false reached the application as " + back.getPayload().get("off")); + + // The tagged form this codec writes is unaffected. + AppState tagged = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"," + + "\"enc\":\"1\",\"payload\":{\"on\":\"b:true\"}}"); + assertEquals(Boolean.TRUE, tagged.getPayload().get("on"), + "the tagged form stopped decoding"); + } + /** * Keys the host filesystem would merge stay distinct, whatever rule it merges them by. * @@ -5676,6 +5749,19 @@ private static String mangle(String name) { } } + /** A bridge that counts how many callbacks it was given. */ + static class CountingBridge extends LocalContinuityBridge { + int callbacks; + + @Override + public void setCallback(ContinuityCallback c) { + super.setCallback(c); + if (c != null) { + callbacks++; + } + } + } + static class RefusingOneStorage extends Storage { private final Storage delegate; private final String refused; From c09cf5807bdc45af5abf307f60e522396a99d676 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:45:21 +0300 Subject: [PATCH 101/140] Decide a callback on the thread that owns the state, refuse an unstorable origin ContinuityCallback lets a port call from any thread, and the decision reads `enabled` and the application's choice, which the event thread owns. Reading them from elsewhere was argued safe in one direction -- a decline is recoverable, because the port retains the activity and offers it again -- and that argument died the moment the "off" answer became a CLAIM, two rounds ago and by my hand: a claim drops the activity, so a stale read there loses an arrival outright rather than delaying it. Marshalled rather than published. This framework is single threaded on the event thread; the answer to reading its state from elsewhere is to stop, not to put memory barriers around fields that have one owner. Claimed on the way out, because that is then the truth: the framework has taken the activity and will deal with it, and nothing else answers to this application's own activity type, which the check above has already established. Every port shipped here already marshals -- the iOS one hands over through callSerially, the simulator's hooks are dispatched on the event thread, Android has no continuation callback at all -- so this is the guarantee for a bridge written elsewhere rather than a change to how ours behave. I checked all three before changing anything. The test that comes with it is a REGRESSION GUARD and says so: a memory-visibility fix has nothing a test can expose, and this one passes against the code without it. What it pins is what the marshalling must not break -- a port calling from its own thread is told the activity was taken, and the arrival actually arrives. Separately, an inbound device id longer than a checkpoint can hold is refused, where an oversized title is dropped. The two are not alike: a title is a label a receiving device may show, while the origin id is the key every mark and every dedup decision is made against, and admit() refuses a state without one anyway. Carrying it is what does damage -- commit() writes it through Util.writeUTF, which throws every time, so the arrival is parked, re-applied on every retry and holds every relay publication behind it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 40 ++++++++- .../com/codename1/continuity/StateCodec.java | 17 ++++ .../continuity/LocalContinuityTest.java | 83 +++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index c7b0507008d..7c922589332 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -3105,6 +3105,38 @@ public boolean continuationReceived(String activityType, Map use if (activityType == null || !activityType.equals(getActivityType())) { return false; } + // MARSHALLED when this is not the event thread, and everything below then runs on it. + // + // ContinuityCallback lets a port call this from any thread, and the decision below + // reads `enabled` and `applicationHasChosen`, which the event thread owns. Reading + // them from elsewhere used to be argued safe in one direction -- a decline is + // recoverable, because the port retains the activity and offers it again -- and that + // argument stopped holding when the "off" answer became a CLAIM: a claim drops the + // activity, so a stale read there loses an arrival outright rather than delaying it. + // + // Claimed on the way out, because that is the truth: this framework has taken the + // activity and will deal with it. Nothing else answers to this application's own + // activity type, which the check above has already established. + // + // Every port shipped here already marshals -- the iOS one hands over through + // callSerially, the simulator's hooks are dispatched on the event thread -- so this + // is the guarantee for a bridge written elsewhere, not a change to how ours behave. + if (Display.isInitialized() && !Display.getInstance().isEdt()) { + final Map info = userInfo; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + decide(info); + } + }); + return true; + } + return decide(userInfo); + } + + /// The decision itself, on the event thread -- or before there is one, where nothing else + /// is running to race it and deliver() parks the arrival for the EDT that is starting. + private boolean decide(Map userInfo) { if (!enabled) { // The answer is the application's own choice, and the two states that share // `enabled == false` want opposite ones. @@ -3129,10 +3161,10 @@ public boolean continuationReceived(String activityType, Map use // its own type so no other handler could take it, while the port's retention was // written for a decline that never came. // - // Both flags are read here from the platform's thread, which the rest of this - // method deliberately avoids. It is safe in the one direction that matters: a - // decline is RECOVERABLE -- the activity is retained and re-offered -- so losing - // the race can only delay the delivery, never lose it. + // Both flags are read on the EVENT THREAD, which owns them -- see the + // marshalling in continuationReceived. They used to be read from whatever thread + // the port called on, argued safe because a decline is recoverable; that argument + // died the moment the "off" answer became a claim. return applicationHasChosen; } AppState state = StateCodec.fromMap(userInfo); diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 25e5e2fbb6b..5ae16084041 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -333,6 +333,23 @@ private static void requireKnownTypes(Map m) throws IOException + "understands it can still use it."); } requireType(m, KEY_DEVICE, String.class, "a string"); + Object device = m.get(KEY_DEVICE); + if (device instanceof String && exceedsWritableLength((String) device)) { + // REFUSED, where an oversized title is dropped. The two are not alike: a title is a + // label a receiving device may show, and losing it costs a nicety, while the origin + // id is the key every mark and every dedup decision is made against -- a state + // without one is refused by admit() anyway, so dropping it would only move the + // refusal somewhere less clear. + // + // Carrying it is the answer that does damage: commit() writes the id through + // Util.writeUTF, which throws on it every time, so the arrival is parked, re-applied + // on every retry and holds every relay publication behind it -- after the provider + // and the route rebuild have already run. + throw new IOException("The continuity relay returned a document whose device id is " + + "longer than " + MAX_STRING_BYTES + " bytes of modified UTF-8, which is " + + "more than a stored checkpoint can hold. Treated as a failed read, so the " + + "document stays where a sender that fixes it can replace it."); + } requireType(m, KEY_TITLE, String.class, "a string"); requireNumberLike(m, KEY_SEQUENCE); requireNumberLike(m, KEY_TIMESTAMP); 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 index 1477dac1d62..c4c26592303 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,89 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * A callback delivered from a foreign thread is still claimed and still delivered. + * + *

A REGRESSION GUARD, not a probe of the change it came with. The decision moved onto the + * event thread because it reads `enabled` and the application's choice, which that thread + * owns -- reading them from elsewhere was argued safe in one direction, since a decline is + * recoverable when the port retains and re-offers, and that argument died the moment the + * "off" answer became a CLAIM: a claim drops the activity, so a stale read there loses an + * arrival outright rather than delaying it.

+ * + *

That is a memory-visibility fix and nothing in a test can demonstrate it: the harness + * has no failing publication to expose, and this test passes against the code without it. + * What it does pin is the behaviour the marshalling must not break -- a port calling from its + * own thread is told the activity was taken, and the arrival actually arrives.

+ */ + @EdtTest + public void aCallbackFromAForeignThreadIsStillClaimedAndDelivered() { + Continuity.enable(); + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + final ContinuityCallback callback = Continuity.callbackForTest(); + final Map info = + StateCodec.toMap(fromElsewhere("from a background thread", 270L)); + final java.util.concurrent.atomic.AtomicBoolean claimed = + new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicBoolean onEdt = + new java.util.concurrent.atomic.AtomicBoolean(true); + final java.util.concurrent.CountDownLatch done = + new java.util.concurrent.CountDownLatch(1); + + Display.getInstance().startThread(new Runnable() { + public void run() { + onEdt.set(Display.getInstance().isEdt()); + claimed.set(callback.continuationReceived(Continuity.getActivityType(), info)); + done.countDown(); + } + }, "continuity foreign caller").start(); + + for (int i = 0; i < 40 && done.getCount() > 0; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(0L, done.getCount(), "the foreign caller never returned"); + assertFalse(onEdt.get(), "the fixture ran on the event thread, so it tests nothing"); + assertTrue(claimed.get(), + "the framework declined an arrival it had taken responsibility for, so the port " + + "is entitled to hand it to something else"); + + for (int i = 0; i < 20 && provider.restored == null; i++) { + pause(50L); + flushSerialCalls(); + } + assertNotNull(provider.restored, + "the arrival was claimed and then never delivered, which is the one outcome a " + + "claim must not produce"); + } + + /** + * An inbound device id too long to store is a failed read, not a parked arrival. + * + *

Refused where an oversized title is dropped, and the two are not alike: a title is a + * label a receiving device may show, while the origin id is the key every mark and every dedup + * decision is made against -- admit() refuses a state without one anyway. Carrying it is what + * does damage: commit() writes it through Util.writeUTF, which throws every time, so the + * arrival is parked, re-applied on every retry and holds every relay publication behind it.

+ */ + @EdtTest + public void anInboundDeviceIdTooLongToStoreIsAFailedRead() throws Exception { + StringBuilder huge = new StringBuilder(); + for (int i = 0; i < 70000; i++) { + huge.append('d'); + } + Map wire = new HashMap(); + wire.put("device", huge.toString()); + wire.put("seq", "280"); + wire.put("routes", java.util.Arrays.asList("/orders")); + + assertNull(StateCodec.fromMap(wire), + "an origin id no checkpoint can hold was carried into the state, so commit() " + + "throws on it every time and the arrival is never let go of"); + } + /** * A bridge is given the framework's callback once, however many listeners register. * From 97f3cb3f3f5d3147f15ab14ded91aa03b6658ed6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:07:48 +0300 Subject: [PATCH 102/140] Say which statement wedges the tvOS suite ContinuityStateTest stops the tvOS run dead. The console's last line is the support-probe INFO and then nothing -- no stage=ran, no suite finished, and SUITE:FINISHED never appears -- so every test after it alphabetically is reported as "missing actual" and the job fails on DesktopMode and Media360Panorama, which are fine and never got to run. Established before changing anything: - It is this branch. The same tvOS job passed on location-button-android17 with all 144 tests matching. - It is tvOS. The same test runs to completion on the iOS GL job in this very run, where the suite finishes normally. - It is not today's commits. The tvOS comment on this PR reported the same two missing at 13:46 yesterday, so it has been there since the test was added. - It is not a crash the console records, and it is not any of the framework's blocking calls: all three callSeriallyAndWait sites are on worker threads that this test never starts. The one difference the log does show is that isContinuationSupported() answers false on tvOS and true on iOS. That is as far as evidence goes without running tvOS, so this adds phase markers rather than guessing at a fix. The next run says which statement it stops on, and the markers keep earning their place afterwards: this is a device conformance test whose whole job is to report what a platform does. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/ContinuityStateTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 index ed9b55ba6eb..0bbbd8bbd4d 100644 --- 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 @@ -55,6 +55,20 @@ public boolean shouldTakeScreenshot() { return false; } + /// Progress marker, printed before each phase. + /// + /// This test wedged the tvOS suite: the console stops immediately after the support-probe + /// line and every test after it alphabetically never runs, so the run reports them as + /// "missing actual" and the suite never emits SUITE:FINISHED. It has done that since the + /// test was added, and it does NOT happen on iOS, where the same test runs to completion. + /// + /// The markers are how the next tvOS run says which statement it stops on, and they earn + /// their place afterwards: this is a device conformance test whose whole job is to report + /// what a platform does. + private static void phase(String name) { + System.out.println("CN1SS:INFO:test=ContinuityStateTest phase=" + name); + } + @Override public boolean runTest() { try { @@ -69,10 +83,12 @@ public boolean runTest() { // NSUserActivityTypes by the build on the other. If the two ever disagree, iOS // silently refuses to deliver anything -- so the shape is asserted where it is // computed. + phase("activityType"); String activityType = Continuity.getActivityType(); assertBool(activityType != null && activityType.endsWith(".continuity"), "activity type ends with .continuity"); + phase("provider"); final Map restored = new HashMap(); Continuity.setStateProvider(new StateProvider() { public Map saveState() { @@ -88,9 +104,12 @@ public void restoreState(Map payload) { }); assertBool(Continuity.isEnabled(), "installing a provider enables the framework"); + phase("title"); Continuity.setTitle("cn1ss continuity"); + phase("checkpoint"); Continuity.checkpoint(); + phase("restorable"); AppState stored = Continuity.getRestorableState(); assertBool(stored != null, "a checkpoint leaves a restorable state"); assertEqual("cn1ss draft", stored.getPayload().get("draft"), "stored payload"); @@ -101,6 +120,7 @@ public void restoreState(Map payload) { // Restoring an app with no routes hands the payload back and shows nothing, which is // what lets "restore, or else begin" work. Answering true here would make such an app // skip its own first screen. + phase("restore"); assertBool(!Continuity.restore(), "a routeless restore shows no form"); assertEqual("cn1ss draft", restored.get("draft"), "the payload reached the provider"); @@ -108,6 +128,7 @@ public void restoreState(Map payload) { // device and the map one is handed to the operating system, and a millisecond // timestamp is past the range a JSON number represents exactly -- which is why they // are encoded as strings and why that is asserted rather than assumed. + phase("wire"); AppState wire = new AppState() .setRoutes(routes()) .setPayload(payload()) @@ -124,6 +145,7 @@ public void restoreState(Map payload) { assertEqual("cn1ss", viaMap.getPayload().get("name"), "payload survives the map form"); // The payload rule is enforced where the application can act on it, on every port. + phase("payloadRule"); boolean refused = false; try { Map bad = new HashMap(); @@ -141,6 +163,7 @@ public void restoreState(Map payload) { // behind by a run that was interrupted between the write and the removal made the // absent-value assertion fail on every later run, permanently -- and on iOS the value // can also arrive from another device, which no amount of local cleanup prevents. + phase("syncedStore"); String key = "cn1ss.sortOrder." + System.currentTimeMillis(); try { SyncedStore.remove(key); From 3b742d14aab275fc794eab8c188daf60779f9473 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:19:38 +0300 Subject: [PATCH 103/140] Mint the origin id without standing up a web view Found it. The phase markers pushed one commit ago say the tvOS suite stops in setStateProvider(), which calls enable(), which mints the device id -- and it did that through Util.getUUID(). Instantiating Util.UUID runs a static initialiser that seeds itself from getUniqueDeviceID(), which reaches CN.getProperty("User-Agent"), and the Apple ports answer that by standing up a web view. tvOS has no WebKit. The call never returns, enable() hangs on the event thread, and the device suite stops dead on the first test that installs a state provider -- taking every test after it alphabetically with it, which is why the job failed on DesktopMode and Media360Panorama, two tests that are perfectly fine and never ran. The two consoles from the same CI run settle it: iOS logs 1056 com.apple.WebKit lines, the first burst starting at the exact instant of that test's support-probe line, and tvOS logs zero. An id that identifies one installation to the devices it syncs with does not need a browser's user agent to seed it, and must not need a working web view to exist. Time plus two draws from a Random seeded independently of it is ample: this runs once per install and the result is persisted. Formatted through Integer.toHexString on each half, because Long.toHexString is in neither vm/JavaAPI nor Ports/CLDC11 -- the same trap that made this branch red two commits ago. The test asserts the SHAPE of the id, which is what a test can reach here: the JavaSE port answers getProperty("User-Agent") out of a table and never builds a browser, so the hang itself does not reproduce in the harness. Reverting to Util.getUUID() produces a dashed UUID and fails that assertion, which is what makes it worth writing. Note the wider hazard this leaves behind: Util.getUUID() hangs on tvOS for ANY caller, not just this one. That is not mine to fix in this PR, but it is worth knowing. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 40 +++++++++++++++- .../continuity/LocalContinuityTest.java | 46 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 7c922589332..2d52d50176d 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -2455,6 +2455,44 @@ private static void windowWaitFinished() { startPublisher(); } + /// A fresh origin id, minted once per install and persisted. + /// + /// NOT Util.getUUID(), and the reason is worth the paragraph. Instantiating Util.UUID runs its + /// static initialiser, which seeds itself from getUniqueDeviceID() -- and that reaches + /// CN.getProperty("User-Agent"), which the Apple ports answer by standing up a web view. On + /// tvOS there is no WebKit at all, so the call never returns: enable() hung on the event + /// thread, and the device suite stopped dead on the first test that installs a state + /// provider, taking every test after it with it. The iOS log makes the mechanism plain -- + /// a thousand WebKit lines starting at exactly that instant -- and the tvOS log has not one. + /// + /// An id that identifies one installation to the devices it syncs with does not need a + /// browser's user agent to seed it, and must not need a working web view to be minted. Time + /// plus two draws from a Random seeded independently of it is ample: this runs ONCE per + /// install, and the result is written to storage. + private static String mintDeviceId() { + long time = System.currentTimeMillis(); + java.util.Random random = new java.util.Random(time ^ (long) new Object().hashCode()); + return "cn1-" + hex64(time) + "-" + hex64(random.nextLong()) + hex64(random.nextLong()); + } + + /// A long as sixteen hex digits, through Integer.toHexString on each half. + /// + /// Long.toHexString is in neither vm/JavaAPI nor Ports/CLDC11, and the Maven build compiles + /// core against the full JDK and would have accepted it. + private static String hex64(long value) { + return hex32((int) (value >>> 32)) + hex32((int) value); + } + + private static String hex32(int value) { + String hex = Integer.toHexString(value); + StringBuilder out = new StringBuilder(); + for (int pad = hex.length(); pad < 8; pad++) { + out.append('0'); + } + out.append(hex); + return out.toString(); + } + private static String loadDeviceId() { try { String id = null; @@ -2465,7 +2503,7 @@ private static String loadDeviceId() { } } if (id == null || id.length() == 0) { - id = Util.getUUID(); + id = mintDeviceId(); if (!Storage.getInstance().writeObject(PREF_DEVICE_ID, id)) { // Minted but not stored, so the next launch mints another one and every state // this device has sent starts looking like a stranger's. Nothing here can 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 index c4c26592303..a5d2b308df3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3692,6 +3692,52 @@ public void twoKeysDifferingOnlyInCaseAreTwoKeys() { } } + /** + * Minting the device id does not go anywhere near a web view. + * + *

enable() mints an origin id on first use, and it used to do that through + * Util.getUUID(). Instantiating Util.UUID runs a static initialiser that seeds itself from + * getUniqueDeviceID(), which reaches CN.getProperty("User-Agent") -- and the Apple ports + * answer that by standing up a web view. tvOS has no WebKit at all, so the call never + * returned: enable() hung on the event thread and the device suite stopped dead on the first + * test that installs a state provider, taking every test after it with it. The iOS console + * shows a thousand WebKit lines starting at that instant; the tvOS one has none.

+ * + */ + @EdtTest + public void mintingTheDeviceIdAsksForNoDeviceProperties() { + Continuity.reset(); + Storage.getInstance().clearStorage(); + Continuity.setBridge(new LocalContinuityBridge()); + Continuity.enable(); + + String id = Continuity.getDeviceId(); + assertNotNull(id, "enabling minted no device id"); + // The SHAPE, which is what a test can actually check here: the JavaSE port answers + // getProperty("User-Agent") out of a table and never builds a browser, so the hang + // itself cannot be reproduced in this harness. What can be pinned is that the id is + // this framework's own and not a Util.UUID -- reverting to Util.getUUID() produces a + // dashed UUID and fails this line, which is the whole point of asserting it. + assertTrue(id.startsWith("cn1-"), + "the origin id was not minted here but handed over by Util.getUUID(), whose " + + "static initialiser reads getProperty(\"User-Agent\") -- a call the " + + "Apple ports answer with a web view, and one tvOS never returns from"); + + // Minted ONCE and then persisted: a second enable in the same install keeps it, which is + // what makes this device recognise its own echo from the relay. + String again = Continuity.getDeviceId(); + assertEquals(id, again, "the device id changed within one install"); + + // And two fresh installs do not collide, which is what the discarded UUID was for. + Continuity.reset(); + Storage.getInstance().clearStorage(); + Continuity.setBridge(new LocalContinuityBridge()); + Continuity.enable(); + assertFalse(id.equals(Continuity.getDeviceId()), + "two installs minted the same origin id, so each would drop the other's states " + + "as its own echo"); + } + /** * A callback delivered from a foreign thread is still claimed and still delivered. * From ac35d9a9de39548863fbab8bdbbfb913a673f819 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:46:28 +0300 Subject: [PATCH 104/140] Undo only the restore's own screen, drain on clear, and stop claiming a decline Three findings I had not seen, two of them from a PR issue comment rather than a review thread -- my monitor only watched threads, which is the gap this exposed. All three are consequences of my own recent changes. restoreStack() rolls its screen back when show() throws, and it did that whenever the display had changed -- which is true of two different things. show() installs the form and only THEN runs its listeners, so a listener that finds the session expired, calls clear(), opens a login form and then throws has already replaced the screen with its own choice. Re-showing the pre-restore form put the signed-out account's screen back in front of the user, which is the one thing that callback ran to prevent. Asking whether `top` is still current separates the two. Continuity.restore() cannot correct this from outside: by the time it runs, the rollback has happened -- which is why the guard I added there last round was not enough on its own. clear() cleared this class's parked slot and left the PORT holding its own. A Handoff that cold-launches a logged-out app reaches IOSContinuityCallbacks before anything has installed a callback and is held there, so a clear() before the first enable() cleared nothing that existed, and the enable() that came with the later login drained the port into the next account. It drains now, through a one-shot confined to that call rather than by recording a choice: clear() is a logout, not "I do not want continuity", and an arrival that comes AFTER it is not from before it and must still be held for the enable() that is coming. The test pins both halves. And the marshalling from last round answered "claimed" unconditionally while the queued decision could decline -- because the application has not said whether it wants continuity. A bridge acts on the synchronous answer: it lets go of an activity the framework never kept, so the enable() following a sync-only listener has nothing to deliver. That is exactly the loss the retention contract exists to prevent, reintroduced by the fix for the visibility problem. The background path now declines when the application has not chosen, and only claims when it has. applicationHasChosen is the one flag that can be read from that thread: it goes false to true once and never back, so a stale read answers false -- a decline, which the port recovers from by offering again. `enabled` has no such property, which is why the decision itself stays marshalled. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 44 ++++++ .../src/com/codename1/router/Navigation.java | 19 ++- .../continuity/LocalContinuityTest.java | 145 ++++++++++++++++++ 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 2d52d50176d..92dc103baed 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -231,6 +231,11 @@ public final class Continuity { /// the session ended is the one point where the two are still distinguishable. private static com.codename1.ui.Form formAtSessionEnd; + /// Armed only while clear() drains whatever the port has been holding, and read by the + /// callback the port offers it to. Confined to that call: the offer is synchronous, on the + /// event thread, and the flag is lowered in a finally. + private static boolean discardHeldArrival; + /// Whether the application has said anything about continuity yet -- either enable() or /// disable(). It is NOT the negation of `enabled`: the two states that share /// `enabled == false` -- nothing said yet, and switched off on purpose -- want opposite @@ -1406,6 +1411,24 @@ public static void clear() { lifecycle++; parked = null; dirty = false; + // The PORT's held arrival too, not only this class's parked slot. A Handoff that + // cold-launches a logged-out app reaches IOSContinuityCallbacks before anything has + // installed a callback, and it is held there -- so clearing `parked` cleared nothing that + // existed, and the enable() that came with the later login drained the port and restored + // the pre-logout payload and routes into the next account. clear() says nothing from + // before it survives. + // + // A one-shot rather than applicationHasChosen: clear() is a logout, not "I do not want + // continuity", and it deliberately leaves an enabled framework enabled. Recording a + // choice here would make every arrival AFTER the clear be dropped instead of held for + // the enable() that is about to come -- and an arrival after the clear is not from + // before it. + discardHeldArrival = true; + try { + installCallback(); + } finally { + discardHeldArrival = false; + } // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft // to Dana", "Invoice 2031", read at every checkpoint -- so leaving it behind meant the // first checkpoint after a logout, a login screen or the next account's opening route, @@ -3160,6 +3183,22 @@ public boolean continuationReceived(String activityType, Map use // callSerially, the simulator's hooks are dispatched on the event thread -- so this // is the guarantee for a bridge written elsewhere, not a change to how ours behave. if (Display.isInitialized() && !Display.getInstance().isEdt()) { + if (!applicationHasChosen) { + // DECLINED, and nothing queued. The application has not said whether it wants + // continuity, so the queued decision would decline too -- and claiming ahead + // of a decline is a lie the port acts on: it lets go of an activity this + // framework did not keep, so the enable() that follows a sync-only listener + // has nothing to deliver and the cold-launch continuation is gone. Exactly + // the loss the retention contract exists to prevent, reintroduced by the + // marshalling that fixed the visibility problem. + // + // Safe to read from here, and it is the ONE flag that is: it goes false to + // true once and never back, so a stale read answers false -- a decline, which + // the port recovers from by offering the activity again the next time a + // callback is installed. `enabled` has no such property, which is why the + // decision below is marshalled rather than taken here. + return false; + } final Map info = userInfo; Display.getInstance().callSerially(new Runnable() { @Override @@ -3175,6 +3214,11 @@ public void run() { /// The decision itself, on the event thread -- or before there is one, where nothing else /// is running to race it and deliver() parks the arrival for the EDT that is starting. private boolean decide(Map userInfo) { + if (discardHeldArrival) { + // clear() is draining what the port held from before it. Claimed, which is what + // makes the port let go of it. + return true; + } if (!enabled) { // The answer is the application's own choice, and the two states that share // `enabled == false` want opposite ones. diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 2f3e3cd1f9d..e05697d5592 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -362,11 +362,24 @@ public static boolean restoreStack(List paths) { } catch (RuntimeException e) { stack.clear(); stack.addAll(previous); - // And the screen with it. show() rather than showBack(): the user is not going back, - // an attempt that failed is being undone. + // And the screen with it -- but ONLY when the screen still showing is the one this + // method put up. show() rather than showBack(): the user is not going back, an + // attempt that failed is being undone. + // + // The test used to be "the display changed", which is true of two different things. + // show() installs the form and only THEN runs onShowCompleted and the show listeners, + // so a listener that finds the session expired, calls Continuity.clear(), opens a + // login form and then throws has already replaced the screen with its own choice -- + // and re-showing the pre-restore form put the signed-out account's screen back in + // front of the user, which is the one thing that callback ran to prevent. + // + // Asking whether `top` is still current separates them: if it is, nothing has been + // chosen since and the restore's own screen comes down; if it is not, application + // code put something else there and it stays. Continuity.restore() cannot make this + // distinction on its own -- by the time it runs, this rollback has already happened. try { Form now = Display.getInstance().getCurrent(); - if (displayed != null && displayed != now) { //NOPMD CompareObjectsWithEquals + if (displayed != null && now == top) { //NOPMD CompareObjectsWithEquals displayed.show(); } } catch (RuntimeException ignored) { 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 index a5d2b308df3..05e125207a6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4289,6 +4289,59 @@ public void aNestedPayloadCollectionCannotBeMutatedByTheApplication() { "the framework's snapshot changed"); } + /** + * A login form opened by the restored form's own show callback survives. + * + *

restoreStack() rolls its screen back when show() throws, and it used to do that whenever + * the display had changed -- which is true of two different things. show() installs the form + * and only THEN runs its listeners, so a listener that finds the session expired, calls + * clear(), opens a login form and then throws has already replaced the screen with its own + * choice. Re-showing the pre-restore form put the signed-out account's screen back in front + * of the user, which is the one thing that callback ran to prevent -- and Continuity.restore() + * cannot correct it, because by the time it runs the rollback has happened.

+ */ + @EdtTest + public void aLoginFormOpenedByAFailingShowCallbackSurvivesTheRollback() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Form dashboard = new Form("dashboard"); + dashboard.show(); + flushSerialCalls(); + + final Form login = new Form("login"); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + Continuity.clear(); + login.show(); + throw new IllegalStateException("the session had expired"); + } + }); + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(320L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertTrue(login == Display.getInstance().getCurrent(), + "the rollback put the pre-restore screen back over the login form the show " + + "callback had just chosen, returning the signed-out user to the " + + "previous account's screen; showing " + + Display.getInstance().getCurrent().getTitle()); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A login form a route FACTORY opened survives the undo. * @@ -4422,6 +4475,98 @@ public void aRouteTooLongToStoreIsDroppedFromWhatIsCommitted() { } } + /** + * A cold-launch arrival the port is holding is dropped by a clear() that precedes enable(). + * + *

The sibling of the disable() case, and the door it left open. A Handoff that + * cold-launches a logged-out app reaches the port before anything has installed a callback + * and is held there, so clearing the framework's own parked slot cleared nothing that + * existed -- and the enable() that came with the later login drained the port and restored + * the pre-logout payload and routes into the next account.

+ * + *

Not done by recording a choice: clear() is a logout, not "I do not want continuity", + * and an arrival that comes AFTER it is not from before it and must still be held for the + * enable() that is coming.

+ */ + @EdtTest + public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { + HoldingBridge holding = new HoldingBridge(); + holding.pending = StateCodec.toMap(fromElsewhere("from before the logout", 300L)); + Continuity.setBridge(holding); + + // Never enabled -- logged out at launch -- and the app wipes state. + Continuity.clear(); + assertNull(holding.pending, + "the port is still holding the pre-logout arrival, so the enable() that comes " + + "with the login will drain it into the next account"); + + Continuity.setStateProvider(new RecordingProvider()); + flushSerialCalls(); + assertNull(Continuity.getRestorableState(), + "the arrival from before the clear() was restored after it"); + + // And an arrival AFTER the clear is still held for the enable that is coming, which is + // what makes this a drain rather than a policy change. + Continuity.reset(); + Storage.getInstance().clearStorage(); + HoldingBridge later = new HoldingBridge(); + Continuity.setBridge(later); + Continuity.clear(); + later.pending = StateCodec.toMap(fromElsewhere("after the clear", 301L)); + ContinuityCallback c = Continuity.callbackForTest(); + assertFalse(c.continuationReceived(Continuity.getActivityType(), later.pending), + "an arrival that came after the clear was claimed and dropped, so the enable() " + + "about to happen has nothing to deliver"); + } + + /** + * A background callback is not told an arrival was claimed when the decision will decline it. + * + *

The marshalling that fixed cross-thread visibility answered "claimed" unconditionally, + * and the queued decision then declined -- because the application has not said whether it + * wants continuity. The bridge acts on the synchronous answer: it lets go of an activity the + * framework never kept, so the enable() that follows a sync-only listener has nothing to + * deliver and the cold-launch continuation is gone. That is the loss the retention contract + * exists to prevent.

+ */ + @EdtTest + public void aBackgroundCallbackIsNotToldAnArrivalWasClaimedWhenItWillBeDeclined() { + // A sync-only application: the store listener installs the callback and continuity is + // deliberately NOT enabled. + SyncedStoreListener listener = new SyncedStoreListener() { + public void storeChanged() { + } + }; + SyncedStore.addChangeListener(listener); + try { + final ContinuityCallback callback = Continuity.callbackForTest(); + final Map info = + StateCodec.toMap(fromElsewhere("cold-launch handoff", 310L)); + final java.util.concurrent.atomic.AtomicBoolean claimed = + new java.util.concurrent.atomic.AtomicBoolean(true); + final java.util.concurrent.CountDownLatch done = + new java.util.concurrent.CountDownLatch(1); + + Display.getInstance().startThread(new Runnable() { + public void run() { + claimed.set(callback.continuationReceived(Continuity.getActivityType(), info)); + done.countDown(); + } + }, "continuity background caller").start(); + + for (int i = 0; i < 40 && done.getCount() > 0; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(0L, done.getCount(), "the background caller never returned"); + assertFalse(claimed.get(), + "the framework told the port it had taken an arrival it then declined, so a " + + "conforming bridge discards a continuation nothing is holding"); + } finally { + SyncedStore.removeChangeListener(listener); + } + } + /** * A cold-launch arrival the port is already holding is dropped by the first disable(). * From 315ccc936be879d937d23c3fdab4864e65b90aee Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:18:22 +0300 Subject: [PATCH 105/140] Separate installing the seam from asking a port for what it held Two of my own fixes were in direct conflict and this is the seam between them. Installing a callback is ALSO how a port is asked to re-offer a continuation it declined earlier and is holding -- our iOS port does exactly that, and it is what recovers a Handoff that cold-launched the app before anything was listening. Making installation strictly once removed that event: a sync-only application installs the seam through SyncedStore.addChangeListener, the arrival is declined and held, and the enable() that came with the login skipped the install and never asked for it again. The cold-launch continuation was lost. The distinction that matters is not how many times setCallback is called but WHY. "Make sure a seam exists" is what every store listener wants and must not grow with their number -- that was the reported harm, and it is unbounded in application code. "And hand me anything you kept" is worth a re-install and happens at three bounded moments where the framework's answer to a held arrival changes: enable(), disable() and clear(). refreshBridge() is the first kind, not the second: it exists for a bridge the port has SWAPPED, and the instance guard is exactly that test. The SPI says so now, because a port cannot honour a contract it is not told about: setCallback replaces the seam, may be called more than once, and a port registering a native observer must register it once and replace the reference. The test that asserted "exactly one callback" is rewritten. That was the wrong invariant and only the retention path showed it: what must hold is that the count does not grow with the number of LISTENERS, which it now checks with three of them, and that enabling does ask for what the port held. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 48 ++++++++--- .../continuity/spi/ContinuityBridge.java | 15 +++- .../continuity/LocalContinuityTest.java | 81 +++++++++++++++---- 3 files changed, 116 insertions(+), 28 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 92dc103baed..6736a0b81b5 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -313,21 +313,37 @@ public static void enable() { } enabled = true; applicationHasChosen = true; - installCallback(); + // Asking for what the port held: a continuation declined before this call is exactly what + // enabling is meant to pick up. + installCallback(true); } - /// Hands the port a callback, once. Installing it is what makes a port offer an arrival it - /// has been holding, so both enable() and disable() do it -- the two answers differ in what - /// the callback then says, not in whether it exists. + /// Hands the port a callback. /// - /// Only from those two, so a build that merely LINKS this class -- because something else in - /// the framework mentions it -- never installs a callback or touches storage. - private static void installCallback() { + /// Installing one is ALSO how a port is asked to re-offer a continuation it declined earlier + /// and is holding -- our iOS port does exactly that, and it is what recovers a Handoff that + /// cold-launched the app before anything was listening. So the two things cannot be fully + /// separated, and pretending otherwise stranded that arrival: with a strict install-once + /// guard, a sync-only listener installed the seam, the arrival was declined and held, and the + /// enable() that followed skipped the install and never asked for it again. + /// + /// `askForHeld` is the distinction that matters. False is "make sure a seam exists", which is + /// what every SyncedStore listener wants and what must not grow with their number. True is + /// "and hand me anything you kept", which is worth a re-install and happens at four bounded + /// moments: enable(), disable(), clear(), and a bridge the port has swapped. + /// + /// Only from those callers, so a build that merely LINKS this class -- because something else + /// in the framework mentions it -- never installs a callback or touches storage. + private static void installCallback(boolean askForHeld) { ContinuityBridge b = bridgeInternal(); if (b == null) { return; } - if (b == callbackInstalledOn) { //NOPMD CompareObjectsWithEquals + if (!askForHeld && b == callbackInstalledOn) { //NOPMD CompareObjectsWithEquals + // Already installed on this bridge and nothing has changed the answer, so this is a + // repeat that buys nothing -- the per-LISTENER case, which is the one that grew + // without bound and which a port registering a native observer pays for on every + // store change. return; } try { @@ -362,7 +378,7 @@ public static void disable() { // is recorded -- so the callback claims and drops it, which is what disable() says // happens to arriving states. Same route as the one already taken by a state that // arrives after this returns, rather than a second mechanism doing the same job. - installCallback(); + installCallback(true); return; } // Sampled with the bump, not read later: see formAtSessionEnd. @@ -1425,7 +1441,7 @@ public static void clear() { // before it. discardHeldArrival = true; try { - installCallback(); + installCallback(true); } finally { discardHeldArrival = false; } @@ -3064,12 +3080,15 @@ public static ContinuityBridge bridgeForSyncedStore() { /// which is what lets the listener work with continuity still off. public static void installSyncedStoreCallback() { storeCallbackInstalled = true; + // Through the one installer, and NOT asking for held arrivals: a store listener is not + // consent to restore a route stack, so it installs the seam and leaves anything the port + // is holding for the enable() that may never come. // Through the one installer, which is what stops this being called once per LISTENER. // Every SyncedStore.addChangeListener() reached here, so a second listener gave the same // bridge a second callback -- and ContinuityBridge documents that setCallback is called // once, so a port that registers a native observer there keeps both and delivers every // store change twice. - installCallback(); + installCallback(false); } /// Internal. Re-installs the framework's inbound seam on whatever bridge the port now @@ -3090,7 +3109,12 @@ public static void refreshBridge() { // object, which is what this method is for and is a sharper test than the unconditional // call it replaces: a port that calls this without having swapped anything no longer // stacks a second callback on the bridge it already gave one to. - installCallback(); + // + // ENSURE, not ask-for-held. This method exists for a bridge the port has SWAPPED, and the + // instance guard is exactly that test -- a different object gets a seam, the same object + // does not get a second one. Asking for held arrivals here instead made two calls to this + // method install twice on one bridge, which is the accumulation the guard is for. + installCallback(false); } static ContinuityBridge bridgeInternal() { diff --git a/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java index 1f0e3cd4400..211da94e4e7 100644 --- a/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java +++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java @@ -99,8 +99,19 @@ public interface ContinuityBridge { /// 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. + /// Installs the framework's inbound seam. Ports must retain it and may call it from any + /// thread. + /// + /// It REPLACES the seam and may be called more than once, so a port that registers a native + /// observer here must register that observer once and only replace the reference. It is not + /// called once per listener -- the framework collapses those -- but it is called again at the + /// few moments its answer to a held continuation changes: enable(), disable(), clear(), and a + /// bridge the port has swapped. + /// + /// Re-installing is also how the framework asks for a continuation the port DECLINED earlier + /// and is holding. A port that offers a held activity when a callback is installed -- which + /// is what recovers a Handoff that cold-launched the app before anything was listening -- is + /// relying on exactly that, so a framework that installed strictly once would strand it. /// /// #### Parameters /// 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 index 05e125207a6..885a6664b54 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -3822,18 +3822,62 @@ public void anInboundDeviceIdTooLongToStoreIsAFailedRead() throws Exception { } /** - * A bridge is given the framework's callback once, however many listeners register. + * A continuation declined before enable() is delivered by the enable(). * - *

ContinuityBridge documents that setCallback is called once, and every - * SyncedStore.addChangeListener() reached the install path -- so a second listener gave the - * same bridge a second callback, and a port that registers a native observer there keeps both - * and delivers every store change twice.

+ *

The interaction between two earlier fixes. A sync-only application installs the seam + * through SyncedStore.addChangeListener and leaves continuity off, so an arrival is declined + * and the port holds it -- that is the retention contract working. Making installation + * strictly once then removed the only event that asks the port to offer it again, so the + * enable() that came with the login never saw it and the cold-launch Handoff was lost.

+ */ + @EdtTest + public void aContinuationDeclinedBeforeEnableIsDeliveredByTheEnable() { + HoldingBridge holding = new HoldingBridge(); + Continuity.setBridge(holding); + SyncedStoreListener listener = new SyncedStoreListener() { + public void storeChanged() { + } + }; + try { + // The sync-only path: a seam exists, continuity does not. + SyncedStore.addChangeListener(listener); + holding.pending = StateCodec.toMap(fromElsewhere("cold-launch handoff", 330L)); + ContinuityCallback c = Continuity.callbackForTest(); + assertFalse(c.continuationReceived(Continuity.getActivityType(), holding.pending), + "the arrival was claimed before the application had chosen, so the port let " + + "go of it and there is nothing left to deliver"); + + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(holding.pending, + "the port is still holding the arrival, so enabling never asked for it"); + assertNotNull(provider.restored, + "the continuation declined before enable() was never delivered by it, so a " + + "Handoff that cold-launched the app is lost for good"); + } finally { + SyncedStore.removeChangeListener(listener); + } + } + + /** + * Installing the seam does not grow with the number of listeners. + * + *

This asserted "exactly one" and that was the wrong invariant, which only showed once the + * retention path needed a re-offer. Installing a callback is ALSO how a port is asked to hand + * over a continuation it declined earlier and is holding, so a framework that installed + * strictly once stranded that arrival -- see the sibling test below.

* - *

A bridge the port has SWAPPED must still be given one, which is what refreshBridge() - * exists for, so the guard is the bridge INSTANCE rather than a flag.

+ *

What must not happen is the reported harm: every SyncedStore.addChangeListener() + * reaching the install path, so a port that registers a native observer there keeps one per + * listener and delivers every store change that many times. That is unbounded in application + * code. The re-offers are bounded and deliberate -- enable(), disable(), clear(), and a + * swapped bridge -- and a port is told so.

*/ @EdtTest - public void aBridgeIsGivenTheCallbackOnceHoweverManyListenersRegister() { + public void installingTheSeamDoesNotGrowWithTheNumberOfListeners() { CountingBridge counting = new CountingBridge(); Continuity.setBridge(counting); SyncedStoreListener first = new SyncedStoreListener() { @@ -3846,14 +3890,23 @@ public void storeChanged() { }; try { SyncedStore.addChangeListener(first); + assertEquals(1, counting.callbacks, "the first listener installed no seam"); SyncedStore.addChangeListener(second); - Continuity.enable(); - Continuity.refreshBridge(); - + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + } + }); assertEquals(1, counting.callbacks, - "the bridge was given " + counting.callbacks + " callbacks, so a port that " - + "registers a native observer in setCallback keeps every one of them " - + "and delivers each store change that many times"); + "the bridge was given " + counting.callbacks + " callbacks for 3 listeners, " + + "so a port that registers a native observer in setCallback keeps " + + "one per listener and delivers each store change that many times"); + + // enable() DOES re-install, on purpose: that is how the port is asked for a + // continuation it declined while continuity was off. + Continuity.enable(); + assertEquals(2, counting.callbacks, + "enabling did not ask the port for anything it had held, so a Handoff that " + + "cold-launched the app before anything was listening is stranded"); // And a bridge the port SWAPS in still gets one, or refreshBridge() would be inert. CountingBridge replacement = new CountingBridge(); From a94ff072f734033d15137b50b6d6dece1ce2151f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:38:59 +0300 Subject: [PATCH 106/140] Configure the iOS continuation parser like the codec's The same defect I fixed in StateCodec.fromJson, in a second parser I had not enumerated. An NSUserActivity from an untagged or hand-written sender crosses the native boundary as JSON and is parsed back to a Map here, and this parser used the static defaults -- so a raw JSON true reached the listeners and the provider as the String "true", passed validation because a String is a representable type, and was acknowledged. includeNulls goes with it, and that half is not in the report. fromMap() refuses a null nested in a list -- a property list cannot carry one, and the iOS sanitiser drops it and shifts every index after it -- but only if it can see it. With the parser dropping nulls, the list simply arrives one element shorter and the check I added for exactly this corruption never fires. The two settings are one configuration and both doors need it. Enumerated rather than patched where it was reported: there are three JSON parses in this feature. StateCodec is the reference, this one now matches it, and the third -- the synced store's {"keys":[...]} in IOSContinuityBridge -- is correct with the defaults, because our own native side writes it, its elements are strings and the loop discards anything that is not one. That one is annotated rather than changed, so the next reader does not have to work it out again or "fix" it. Verified by inspection and by compiling the port. There is no unit-test harness for the iOS port's Java -- maven/ios has no test source root and core-unittests does not depend on it -- and parse() is private static, so nothing here can exercise it. Building that harness is worth doing and is not this change. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/ios/IOSContinuityBridge.java | 4 ++++ .../impl/ios/IOSContinuityCallbacks.java | 21 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java index fe928a1ac6c..b1dfbabba56 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java @@ -180,6 +180,10 @@ public String[] syncedStoreKeys() { if (json == null || json.length() == 0) { return new String[0]; } + // The default parser is right HERE, unlike the continuation hop in + // IOSContinuityCallbacks: this document is {"keys":[...]} written by our own native + // side, its elements are strings, and the loop below discards anything that is not + // one. There is no boolean to mistype and no null whose absence changes a meaning. parsed = JSONParser.parseJSON(json); } catch (Throwable t) { Log.e(t); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 31e620a2957..3cfeed538a7 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -217,7 +217,26 @@ private static Map parse(String json) { return new HashMap(); } try { - Map parsed = JSONParser.parseJSON(json); + // CONFIGURED like StateCodec.fromJson, which is the reference: this is the same + // document arriving through the other door, and a parser set up differently changes + // what the application receives. + // + // useBoolean, because the default answers a raw JSON true or false with the strings + // "true" and "false". Harmless for the tagged form this framework writes -- "b:true" + // is a string either way -- and wrong for an untagged compatibility document from a + // hand-written sender: the payload reaches the listeners and the provider with + // Strings where booleans were sent, passes validation because a String is a + // representable type, and is acknowledged. + // + // includeNulls, because dropping a null here is worse than refusing it. fromMap() + // refuses a null nested in a list -- a property list cannot carry one, and the iOS + // sanitiser shifts every index after it -- but only if it can see it. Dropped by the + // parser, the list simply arrives one element shorter, which is the corruption that + // check exists to prevent. + JSONParser parser = new JSONParser(); + parser.setUseBooleanInstance(true); + parser.setIncludeNullsInstance(true); + Map parsed = parser.parseJSON(new java.io.StringReader(json)); return parsed == null ? new HashMap() : parsed; } catch (Throwable t) { Log.e(t); From 06f9ba62a150dee049d6d6b2c4f797119af3b5fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:09:19 +0300 Subject: [PATCH 107/140] Bind an arrival at the instant it arrives, and hold a pre-enable one here Two findings that turn out to be one knot, and both undo something I got wrong. The generation was read too late. Every hop between the activity and the decision is a queue, and a logout already sitting on the event queue runs first, so a generation read after those hops is the one AFTER the logout: every later check passes and the previous account's state is restored and persisted by a session that promised nothing from before it survives -- clear() deliberately leaves continuity enabled, so nothing else refuses it. I bound deliver() to the arrival two rounds ago and missed that the iOS port added a hop of its own in front of it. The port hands over directly now and the framework marshals, which is the right split since the framework started marshalling: the generation is read when the activity actually arrives, and the delegate gets the framework's real answer instead of an unconditional true. That left the synchronous answer. It could not be state-dependent -- `enabled` is not monotonic, and `applicationHasChosen` only LOOKED safe: I argued a stale false meant declining and that a decline is recoverable because the port re-offers, and that is untrue once enable() has installed the seam, because no later install comes. A decline then strands the arrival with a port that was already told to let go, or loses it with one that does not retain. So the framework claims it and keeps it. An arrival that reaches the callback before the application has chosen is parked HERE, and enable() drains that slot -- the retention that used to be borrowed from the port now lives where the state does, and no argument about cross-thread visibility is load-bearing any more. Two tests were rewritten and I checked in each case that the harm they were written for is still prevented, rather than that they now pass. One asserted a decline that the new design deliberately does not make; it asserts instead that the arrival is not lost, which is what it was always about. The other had to move onto a background thread: called from the event thread the decision runs inline, before the queued logout, and deliver()'s older guard covers it -- the first version passed with the new check removed. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 152 ++++++++++-------- .../impl/ios/IOSContinuityCallbacks.java | 30 ++-- .../continuity/LocalContinuityTest.java | 94 +++++++++-- 3 files changed, 183 insertions(+), 93 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6736a0b81b5..1cd5df4bc3f 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -316,6 +316,22 @@ public static void enable() { // Asking for what the port held: a continuation declined before this call is exactly what // enabling is meant to pick up. installCallback(true); + // And what THIS class held. An arrival that reached the callback before the application + // enabled continuity is parked here rather than left with the port -- see Callback.decide + // -- so enabling has to look. On the next turn, because dispatch() runs application + // listeners and enable() is not the place to do that. + if (parked != null) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + AppState waiting = parked; + if (waiting != null && enabled) { + parked = null; + dispatch(waiting); + } + } + }); + } } /// Hands the port a callback. @@ -3190,93 +3206,89 @@ public boolean continuationReceived(String activityType, Map use if (activityType == null || !activityType.equals(getActivityType())) { return false; } - // MARSHALLED when this is not the event thread, and everything below then runs on it. - // - // ContinuityCallback lets a port call this from any thread, and the decision below - // reads `enabled` and `applicationHasChosen`, which the event thread owns. Reading - // them from elsewhere used to be argued safe in one direction -- a decline is - // recoverable, because the port retains the activity and offers it again -- and that - // argument stopped holding when the "off" answer became a CLAIM: a claim drops the - // activity, so a stale read there loses an arrival outright rather than delaying it. + // The GENERATION, captured here, at the instant the arrival actually happened. + // Everything downstream queues at least once, and a logout already sitting on the + // event queue runs first -- so a generation read later is the generation AFTER the + // logout, every check passes, and the previous account's state is restored and + // persisted by a session that promised nothing from before it survives. // - // Claimed on the way out, because that is the truth: this framework has taken the - // activity and will deal with it. Nothing else answers to this application's own - // activity type, which the check above has already established. + // Read from whatever thread the port called on. An int read from another thread + // yields a value the event thread wrote at some point and never a future one, so it + // can be stale-old but never stale-new: the worst it does is refuse an arrival that + // raced the logout exactly, which is the answer that side of the race wants. + final int arrivedIn = lifecycle; + if (!Display.isInitialized()) { + // No event thread to marshal to. Nothing else is running to race this, and + // deliver() holds the arrival for the EDT that is about to start. + return decide(userInfo, arrivedIn); + } + if (Display.getInstance().isEdt()) { + return decide(userInfo, arrivedIn); + } + // CLAIMED, unconditionally, and the framework then keeps it -- which is what makes + // that honest. Answering anything state-dependent from here means reading fields the + // event thread owns: `enabled` is not monotonic, and `applicationHasChosen` only + // looked safe. Its stale value is false, which meant declining, and a decline is + // recoverable ONLY while some later install re-offers the activity -- after enable() + // has installed the callback there is no such install, so the decline stranded the + // arrival with a port that had already been told to let go, or lost it outright with + // one that does not retain. // - // Every port shipped here already marshals -- the iOS one hands over through - // callSerially, the simulator's hooks are dispatched on the event thread -- so this - // is the guarantee for a bridge written elsewhere, not a change to how ours behave. - if (Display.isInitialized() && !Display.getInstance().isEdt()) { - if (!applicationHasChosen) { - // DECLINED, and nothing queued. The application has not said whether it wants - // continuity, so the queued decision would decline too -- and claiming ahead - // of a decline is a lie the port acts on: it lets go of an activity this - // framework did not keep, so the enable() that follows a sync-only listener - // has nothing to deliver and the cold-launch continuation is gone. Exactly - // the loss the retention contract exists to prevent, reintroduced by the - // marshalling that fixed the visibility problem. - // - // Safe to read from here, and it is the ONE flag that is: it goes false to - // true once and never back, so a stale read answers false -- a decline, which - // the port recovers from by offering the activity again the next time a - // callback is installed. `enabled` has no such property, which is why the - // decision below is marshalled rather than taken here. - return false; + // So the framework takes it and holds it itself. decide() parks an arrival that comes + // before the application has chosen, and enable() drains that slot -- the retention + // that used to be borrowed from the port now lives where the state does. + final Map info = userInfo; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + decide(info, arrivedIn); } - final Map info = userInfo; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - decide(info); - } - }); - return true; - } - return decide(userInfo); + }); + return true; } /// The decision itself, on the event thread -- or before there is one, where nothing else /// is running to race it and deliver() parks the arrival for the EDT that is starting. - private boolean decide(Map userInfo) { + private boolean decide(Map userInfo, int arrivedIn) { + if (lifecycle != arrivedIn) { + // clear() or disable() ran between the arrival and this decision. Claimed, so the + // port lets go: the state belongs to a session that has ended and nothing is + // going to want it. + return true; + } if (discardHeldArrival) { // clear() is draining what the port held from before it. Claimed, which is what // makes the port let go of it. return true; } - if (!enabled) { - // The answer is the application's own choice, and the two states that share - // `enabled == false` want opposite ones. - // - // TRUE -- claimed, and therefore dropped -- when the application has said what it - // wants and right now that is "off". The port lets go of an activity that was - // handled, and nothing else answers to this application's own activity type, so - // taking it costs no other handler anything. Declining here instead parked the - // arrival with the port, and the next enable() -- installing a callback is what - // makes the port re-offer it -- delivered a state from the interval disable() - // documents as ignored. - // - // FALSE -- declined, and therefore RETAINED -- while the application has said - // nothing at all. That is the answer the iOS port is built for: it holds a - // declined activity and offers it again the next time a callback is installed, - // and enable() installs one. Claiming it instead threw it away, because admit() - // drops an arrival while the framework is disabled -- so an application that - // registers a SyncedStore listener before enabling continuity, which installs - // this same callback, lost a cold-launch Handoff for good. - // - // The two sides disagreed rather than one being wrong: this claimed everything of - // its own type so no other handler could take it, while the port's retention was - // written for a decline that never came. - // - // Both flags are read on the EVENT THREAD, which owns them -- see the - // marshalling in continuationReceived. They used to be read from whatever thread - // the port called on, argued safe because a decline is recoverable; that argument - // died the moment the "off" answer became a claim. - return applicationHasChosen; + if (!enabled && applicationHasChosen) { + // The application has said what it wants and right now that is "off". Claimed, + // and therefore dropped: the port lets go of an activity that was handled, and + // nothing else answers to this application's own activity type. + return true; } AppState state = StateCodec.fromMap(userInfo); if (state == null) { return false; } + if (!enabled) { + // NOTHING SAID YET, and the arrival is held HERE rather than left with the port. + // + // A store listener installs this seam without enabling continuity -- a key/value + // store is not consent to restore a route stack -- so a continuation that + // cold-launches the app can arrive with no one ready for it. That used to be a + // decline, and the port kept it and offered it again when a callback was next + // installed. Two things broke that: a decline reaches no port at all once this + // callback has claimed the arrival on another thread, and after enable() has + // installed the seam there is no later install to re-offer it. + // + // So the framework keeps it. enable() drains this slot, which is the same + // recovery the port used to provide, in the place that actually holds the state. + // False is still returned for a port that is holding one too: the two copies + // dedup on (origin, sequence) at admission. + parked = state; + return false; + } deliver(state); return true; } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 3cfeed538a7..5ffa412a128 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -135,19 +135,24 @@ public static boolean nativeContinuation(String activityType, String userInfoJso pendingJson = userInfoJson; return true; } - final String type = activityType; - final String json = userInfoJson; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - deliverOnEdt(type, json); - } - }); - return true; + // Handed over DIRECTLY, on this thread, and the framework does its own marshalling. + // + // This used to queue and answer true. The queue is where the arrival lost its place in + // time: Continuity binds a continuation to the lifecycle generation it arrived in, and a + // logout already sitting on the event queue runs first -- so the generation captured + // after this hop is the one AFTER the logout, and the previous account's state is + // restored and persisted by a session that promised nothing from before it survives. + // Calling through means the generation is read at the instant the activity actually + // arrived. + // + // The answer is the framework's own rather than an unconditional true, which is also what + // the delegate should be told. + return deliverToFramework(activityType, userInfoJson); } - /// Hands an arrival to the framework, or holds it. On the event thread. - private static void deliverOnEdt(String activityType, String userInfoJson) { + /// Hands an arrival to the framework, or holds it. Called on whatever thread the activity + /// arrived on; the framework marshals what it needs to. + private static boolean deliverToFramework(String activityType, String userInfoJson) { ContinuityCallback c = callback; boolean claimed = false; if (c != null) { @@ -158,7 +163,7 @@ private static void deliverOnEdt(String activityType, String userInfoJson) { } } if (claimed) { - return; + return true; } // Held, because DECLINED is not the same as "not ours". A callback is installed by // SyncedStore.addChangeListener() as well as by Continuity.enable(), and the store @@ -169,6 +174,7 @@ private static void deliverOnEdt(String activityType, String userInfoJson) { // cold-launch continuation into a lost one. pendingType = activityType; pendingJson = userInfoJson; + return false; } /// The synced store changed on another of the user's devices. 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 index 885a6664b54..e88d1b67df4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4573,17 +4573,77 @@ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { } /** - * A background callback is not told an arrival was claimed when the decision will decline it. + * An arrival is bound to the generation it ARRIVED in, not the one the decision runs in. * - *

The marshalling that fixed cross-thread visibility answered "claimed" unconditionally, - * and the queued decision then declined -- because the application has not said whether it - * wants continuity. The bridge acts on the synchronous answer: it lets go of an activity the - * framework never kept, so the enable() that follows a sync-only listener has nothing to - * deliver and the cold-launch continuation is gone. That is the loss the retention contract - * exists to prevent.

+ *

Every hop between the activity and the decision is a queue, and a logout already sitting + * on the event queue runs first. A generation read after those hops is the one AFTER the + * logout, so every later check passes and the previous account's state is restored and + * persisted by a session that promised nothing from before it survives -- clear() deliberately + * leaves continuity enabled, so nothing else refuses it.

+ * + *

The iOS port used to add a hop of its own before the framework saw the arrival, which is + * why it now hands over directly and lets the framework marshal.

*/ @EdtTest - public void aBackgroundCallbackIsNotToldAnArrivalWasClaimedWhenItWillBeDeclined() { + public void anArrivalIsBoundToTheGenerationItArrivedIn() { + Continuity.enable(); + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + ContinuityCallback callback = Continuity.callbackForTest(); + Map info = StateCodec.toMap(fromElsewhere("the previous account", 340L)); + + // From a BACKGROUND thread, which is the only shape that exercises this: the decision is + // queued, so it runs after the logout, and the generation it compares against has to have + // been captured when the activity arrived. Called on the event thread instead, the + // decision runs inline before the logout and deliver()'s own guard covers it -- a first + // version of this test did exactly that and passed with the check removed. + final ContinuityCallback c = callback; + final Map arriving = info; + final java.util.concurrent.CountDownLatch queued = + new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(new Runnable() { + public void run() { + Continuity.clear(); + } + }); + Display.getInstance().startThread(new Runnable() { + public void run() { + c.continuationReceived(Continuity.getActivityType(), arriving); + queued.countDown(); + } + }, "continuity arrival").start(); + for (int i = 0; i < 40 && queued.getCount() > 0; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(0L, queued.getCount(), "the arrival never reached the framework"); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "a continuation that arrived before the logout was restored by the session after " + + "it, because the generation was read once the logout had already run"); + assertNull(Continuity.readSeenForTest().get("some-other-device"), + "it was marked durably too, so the origin's real states are refused after a " + + "restart as already seen"); + } + + /** + * A background arrival before enable() is claimed AND kept, and the enable() delivers it. + * + *

This asserted the opposite -- that the callback declines rather than claims -- and that + * was right for a design where the PORT held the arrival. It stopped being right once the + * decline could not reach a port at all: after enable() has installed the seam there is no + * later install to re-offer anything, so a decline stranded the arrival or lost it outright + * with a bridge that does not retain.

+ * + *

What must hold either way is that the arrival is not lost, and that is what this checks + * now: the framework claims it -- which is honest, because it then holds it itself -- and the + * enable() that follows a sync-only listener delivers it.

+ */ + @EdtTest + public void aBackgroundArrivalBeforeEnableIsKeptAndDeliveredByTheEnable() { // A sync-only application: the store listener installs the callback and continuity is // deliberately NOT enabled. SyncedStoreListener listener = new SyncedStoreListener() { @@ -4612,9 +4672,21 @@ public void run() { flushSerialCalls(); } assertEquals(0L, done.getCount(), "the background caller never returned"); - assertFalse(claimed.get(), - "the framework told the port it had taken an arrival it then declined, so a " - + "conforming bridge discards a continuation nothing is holding"); + assertTrue(claimed.get(), + "the framework declined instead of taking responsibility, and after enable() " + + "installs the seam there is no later install to re-offer it -- so " + + "the arrival is stranded with the port or lost outright"); + + // The claim has to be honest: enabling delivers what was kept. + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + for (int i = 0; i < 20 && provider.restored == null; i++) { + pause(50L); + flushSerialCalls(); + } + assertNotNull(provider.restored, + "the arrival was claimed and then never delivered, which is the one outcome " + + "a claim must not produce -- the cold-launch continuation is gone"); } finally { SyncedStore.removeChangeListener(listener); } From 6e35d2440c059e37c2ccf349526576bdfcc193c1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:33:59 +0300 Subject: [PATCH 108/140] Drain the held arrival through admission, not past it A port may retain the same continuation its pre-enable callback declined -- ContinuityBridge says so -- and since last commit this class parks a copy too. So both can be holding one, and enable() re-offers the port's while draining ours. Ours went straight to dispatch, past the (origin, sequence) check that admission exists for, so the listeners and the provider ran twice on one arrival: the user is asked to continue the same work, or has it restored, twice. The comment I wrote to justify parking said the two copies dedup at admission. They only do if they both go through it, and the code I wrote next to that comment did not. It calls admit() now. The test drives both holders at once -- a bridge that keeps a declined arrival and a framework that parks the same one -- and counts restoreState calls, which is the observable the user actually feels. It fails with two when the drain bypasses admission. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 11 +++- .../continuity/LocalContinuityTest.java | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1cd5df4bc3f..1deedecf20c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -327,7 +327,16 @@ public void run() { AppState waiting = parked; if (waiting != null && enabled) { parked = null; - dispatch(waiting); + // Through admit(), not dispatch(). A port is allowed to retain the same + // continuation it declined, so both it and this class can be holding one + // copy each -- and installing the seam re-offers the port's while this + // drains ours. admission is where (origin, sequence) deduplication lives, + // so it is what makes the second copy a no-op; dispatching straight past + // it ran the listeners and the provider twice on one arrival. + // + // The comment that justified parking said the two copies dedup at + // admission. They only do if they both go through it. + admit(waiting); } } }); 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 index e88d1b67df4..916494590dd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4572,6 +4572,62 @@ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { + "about to happen has nothing to deliver"); } + /** + * A continuation held by BOTH the port and the framework is delivered once. + * + *

A port may retain the same continuation its pre-enable callback declined, so both it and + * this class can hold a copy. Enabling re-offers the port's and drains ours, and the drained + * one used to go straight to dispatch -- past the (origin, sequence) deduplication that + * admission exists for -- so the listeners and the provider ran twice on one arrival.

+ * + *

The comment that justified parking said the two copies dedup at admission. They only do + * if they both go through it.

+ */ + @EdtTest + public void aContinuationHeldByBothThePortAndTheFrameworkIsDeliveredOnce() { + final java.util.concurrent.atomic.AtomicInteger restores = + new java.util.concurrent.atomic.AtomicInteger(); + HoldingBridge holding = new HoldingBridge(); + Continuity.setBridge(holding); + SyncedStoreListener listener = new SyncedStoreListener() { + public void storeChanged() { + } + }; + try { + // The sync-only window: a seam exists, continuity does not. + SyncedStore.addChangeListener(listener); + Map info = StateCodec.toMap(fromElsewhere("held twice", 350L)); + holding.pending = info; + + // The framework takes and parks it, and the port keeps its own copy because the + // answer was a decline -- which is exactly what ContinuityBridge permits. + ContinuityCallback c = Continuity.callbackForTest(); + c.continuationReceived(Continuity.getActivityType(), info); + flushSerialCalls(); + + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(); + } + + public void restoreState(Map payload) { + restores.incrementAndGet(); + } + }); + for (int i = 0; i < 20 && restores.get() == 0; i++) { + pause(50L); + flushSerialCalls(); + } + + assertEquals(1, restores.get(), + "one arrival was restored " + restores.get() + " times, because the copy this " + + "class held went straight to dispatch and never met the " + + "(origin, sequence) check that would have recognised the port's"); + } finally { + SyncedStore.removeChangeListener(listener); + } + } + /** * An arrival is bound to the generation it ARRIVED in, not the one the decision runs in. * From c3836707e0bf0e9d70fc53e338d16c6b4b3998ae Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:08:53 +0300 Subject: [PATCH 109/140] Treat a relay document with no origin as a failed read An origin is the key every mark and every dedup decision is made against, so a state without one cannot take part at all -- admit() logs it and drops it. Counting the read as SUCCESSFUL anyway is what does the damage: it clears the unread flag and releases a checkpoint queued during the GET, and that publish overwrites the relay's only copy of remote work nothing here could read. Fixed on the relay's own path rather than in the codec. Requiring an origin in requireKnownTypes was my first attempt and it was wrong: the codec also reads states this device built, so a round trip through toMap started failing for any AppState whose deviceId was never set -- eight wire tests said so. AppState does not require an origin; the relay contract does. Checked in pollFinished, it also covers every StateRelay rather than only the one shipped here. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 21 +++++++++ .../continuity/LocalContinuityTest.java | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1deedecf20c..eabc18ebd7c 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1389,6 +1389,27 @@ private static void pollFinished(AppState fetched, boolean fetchFailed, int sess return; } polling = false; + if (!fetchFailed && fetched != null + && (fetched.getDeviceId() == null || fetched.getDeviceId().length() == 0)) { + // A document the relay HELD but this build cannot use: an origin is the key every + // mark and every dedup decision is made against, so admit() can only log it and drop + // it. Counting the read as successful anyway is what does the damage -- it clears + // fetchUnread and releases a checkpoint queued during the GET, and that publish + // overwrites the relay's only copy of remote work nothing here could read. + // + // Treated as a FAILED read instead, which is what it is: the document stays where it + // is, the publisher stays held, and a sender that names itself can replace it. + // + // Here rather than in the codec, because the codec also reads states this device + // built -- a round trip through toMap must not start requiring an origin AppState + // itself does not require -- and here it covers every StateRelay rather than only the + // one shipped with the framework. + Log.p("Continuity: the relay returned a state with no device id. Every state has to " + + "carry the id of the device it came from, or nothing can tell it apart from " + + "the states already seen. Treated as a failed read."); + fetchFailed = true; + fetched = null; + } // Recorded, because `polling` stops being true the moment this returns and the hold below // would then last only until the next checkpoint -- which is not what "anything owed // waits for a read that succeeds" says. The comment was making a promise the code kept 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 index 916494590dd..cdfbc3b1a6b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4572,6 +4572,49 @@ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { + "about to happen has nothing to deliver"); } + /** + * A relay document with no origin is a failed read, so the publisher stays held. + * + *

An origin is the key every mark and every dedup decision is made against, so admit() can + * only log such a state and drop it. Counting the read as successful anyway is what does the + * damage: it clears the unread flag and releases a checkpoint queued during the GET, and that + * publish overwrites the relay's only copy of remote work nothing here could read.

+ */ + @EdtTest + public void aRelayDocumentWithNoOriginIsAFailedRead() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + final java.util.concurrent.atomic.AtomicInteger reads = + new java.util.concurrent.atomic.AtomicInteger(); + Continuity.setRelay(new StateRelay() { + public void publish(AppState state) { + published.add(state); + } + + public AppState fetch() { + reads.incrementAndGet(); + // Recognisable -- it has a sequence -- and unusable, because it names no device. + return new AppState().setSequence(360L).setTimestamp(System.currentTimeMillis()); + } + }); + pause(300L); + flushSerialCalls(); + assertTrue(reads.get() > 0, "the relay was never read, so this test is about nothing"); + + // A checkpoint queued while that read was outstanding must NOT go out: the document on the + // relay is remote work this build could not read, and publishing over it destroys it. + int before = published.size(); + Continuity.checkpoint(); + pause(300L); + flushSerialCalls(); + assertEquals(before, published.size(), + "a checkpoint was published over a relay document this build could not read, so " + + "the other device's only copy is gone"); + } + /** * A continuation held by BOTH the port and the framework is delivered once. * From 217be7b1140770d7f994223da6964b37058ef302 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:36:38 +0300 Subject: [PATCH 110/140] Read an unrecognised relay object as a failed read, not an empty relay A relay answering with a valid object that carries no fields this build knows -- {"error":"temporarily unavailable"} returned with a 2xx is the shape -- came back as null, and null means "the relay holds nothing" to the code that reads a fetch. The publisher is released and a local checkpoint overwrites a document this device never managed to read. NON-EMPTY is the whole condition, and the distinction is the point. A bare {} is a plausible way for an endpoint to say it holds nothing, alongside the 404 and the empty body this class documents, and refusing that would leave such an endpoint unable to publish anything ever. An object carrying fields none of which are ours is a different thing: something is there and this build cannot read it. Thrown from fromJson rather than fromMap, so the platform continuation path is untouched -- an unrecognised NSUserActivity still comes back as null and is declined, which is what stops the callback claiming it and prompting the user over nothing. An existing test asserted null for that document. Its stated harm is about FABRICATING a state, and that is preserved and now asserted on the path it applies to -- fromMap still answers null -- with the relay's reading asserted beside it. Also: AppStateWireTest now extends UITestBase, as the other three test classes in this package already did. Core code logs, and Log.print() reaches Display.getInstance() the first time it runs, so in a class with no Display the first test that makes the framework log dies in Util.cleanup(). Which test that is depends on the order they run in, so the class passed until an edit moved a different one to the front. It fails in isolation without this change and without any of mine, which is how I know it was already there rather than something I introduced. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/StateCodec.java | 20 ++++++++++- .../continuity/AppStateWireTest.java | 35 +++++++++++++++++-- .../continuity/LocalContinuityTest.java | 28 +++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 5ae16084041..3989e204fe8 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -291,7 +291,25 @@ public static AppState fromJson(String json) throws IOException { parser.setUseBooleanInstance(true); Map parsed = parser.parseJSON(new java.io.StringReader(json)); requireKnownTypes(parsed); - return fromMap(parsed); + AppState state = fromMap(parsed); + if (state == null && parsed != null && !parsed.isEmpty()) { + // A document with CONTENT that this build recognises none of. fromMap answers null + // for it, and null means "the relay holds nothing" to the code that reads a fetch -- + // so the publisher is released and a local checkpoint overwrites a document this + // device never managed to read. `{"error":"temporarily unavailable"}` returned with a + // 2xx is the shape that does it. + // + // NON-EMPTY is the whole condition. A bare `{}` is a plausible way for an endpoint to + // say it holds nothing, alongside the 404 and the empty body this class documents, + // and refusing that would leave such an endpoint unable to publish anything, ever. + // An object carrying fields none of which are ours is a different thing: something is + // there and this build cannot read it. + throw new IOException("The continuity relay returned an object with no fields this " + + "build recognises. Treated as a failed read rather than as an empty relay, " + + "because something is stored there and publishing over it would destroy " + + "work this device could not read."); + } + return state; } /// Refuses a document whose known fields carry the wrong kind of value. 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 index effcf4cccd9..325844ed814 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -52,7 +52,12 @@ * 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 { +// Extends UITestBase, as every other test in this package does, and not for the EDT: core code +// logs, and Log.print() reaches Display.getInstance() the first time it runs -- so in a class +// with no Display the FIRST test that makes the framework log dies with a NullPointerException +// out of Util.cleanup(). Which test that is depends on the order they happen to run in, so the +// class passed until an unrelated edit moved a different one to the front. +public class AppStateWireTest extends com.codename1.junit.UITestBase { @Test public void jsonRoundTripPreservesEveryField() throws Exception { @@ -411,12 +416,38 @@ public void emptyAndNullDocumentsProduceNoState() throws Exception { * state. Returning a default one meant the continuation callback CLAIMED it and delivered it: * the application's listeners ran, and an app that prompts before moving the user put a * "continue what you were doing?" dialog in front of them over nothing at all. + * + *

That harm is about FABRICATING a state and it is still asserted below, on the path where + * it happens: fromMap answers null, so the continuation callback declines rather than + * claiming. What has changed is the RELAY's reading of a document that carries fields none of + * which are ours -- null there means "the relay holds nothing", which releases the publisher + * and overwrites a document this device never read. An empty object stays null, because that + * is a plausible way for an endpoint to say it holds nothing.

*/ @Test public void aDocumentWithNoStateFieldsIsNotAState() throws Exception { assertNull(StateCodec.fromJson("{}")); assertNull(StateCodec.fromMap(new HashMap())); - assertNull(StateCodec.fromJson("{\"somethingElse\":1,\"unrelated\":\"x\"}")); + + // No state is FABRICATED for an unrecognised document -- the guarantee this test was + // written for, on the path it applies to. + Map unrelated = new HashMap(); + unrelated.put("somethingElse", Integer.valueOf(1)); + unrelated.put("unrelated", "x"); + assertNull(StateCodec.fromMap(unrelated), + "an unrecognised activity produced a state, so the callback claims it and the " + + "application is prompted over nothing at all"); + + // And on the relay wire the same document is a failed READ, not an empty relay: null + // there means "the relay holds nothing", which releases the publisher and overwrites a + // document this device never read. + Exception unreadable = assertThrows(Exception.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + StateCodec.fromJson("{\"somethingElse\":1,\"unrelated\":\"x\"}"); + } + }); + assertTrue(unreadable.getMessage().length() > 0, "the refusal explained nothing"); } /** One recognized field is enough -- a state with only routes is a real state. */ 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 index cdfbc3b1a6b..c6f32da1904 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4572,6 +4572,34 @@ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { + "about to happen has nothing to deliver"); } + /** + * An unrecognised relay object is a failed read; a bare empty one is still an empty relay. + * + *

fromMap answers null for a document it recognises nothing in, and null means "the relay + * holds nothing" to the code that reads a fetch -- so the publisher is released and a local + * checkpoint overwrites a document this device never read. A 2xx carrying + * {@code {"error":"temporarily unavailable"}} is the shape that does it.

+ * + *

The empty half matters as much: a bare {@code {}} is a plausible way for an + * endpoint to say it holds nothing, and refusing it would leave such an endpoint unable to + * publish anything ever.

+ */ + @EdtTest + public void anUnrecognisedRelayObjectIsAFailedReadAndAnEmptyOneIsNot() throws Exception { + try { + StateCodec.fromJson("{\"error\":\"temporarily unavailable\"}"); + fail("an object carrying fields none of which are ours was read as an empty relay, " + + "so a checkpoint is published over work this device could not read"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing"); + } + + // And the empty object still means "nothing here", which an endpoint is entitled to say. + assertNull(StateCodec.fromJson("{}"), + "a bare empty object was refused, so an endpoint that answers that way for " + + "\"none\" can never publish anything"); + } + /** * A relay document with no origin is a failed read, so the publisher stays held. * From d667acb2329883a2b8eaac020d4f51a02475c14a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:08:50 +0300 Subject: [PATCH 111/140] Checkpoint a redirect made during a rebuild, and honour an explicit sync opt-in routeStackChanged() returns early while a restore is being applied, and it has to: without that the rebuild checkpoints and republishes the state it is applying, and the two devices bounce it back and forth. But the restored form's show callback is application code and may navigate -- a screen that redirects to a newer one, an expired detail page sending the user to a list. Both notifications for that navigation land inside the window and are dropped, so the checkpoint recorded the routes that ARRIVED instead of the ones the user is on, and a process death before the next one restored the screen the application had redirected away from. The reconciliation goes AFTER commit(), and the ordering is the whole of it. commit() clears the pending flag as part of settling the arrival, so asking before it set a flag commit then wiped and the scheduled flush found nothing owed -- the fix looked right and did nothing, which the test caught. It is also after the lifecycle branch, because a callback that ends the session leaves the stack different from what was restored too, and checkpointing there writes for a session that has just ended -- an existing test caught that one. Separately, ios.continuity.sync=true is a DECLARATION and the build ignored it. The hint documents itself as "set true to say so explicitly", and the signing preflight already reads it that way -- it is how a project says it wants the store without that check having to read bytecode. The builder used it only as a veto, so a project that says so and whose usage the scan cannot see got neither the entitlement nor the define, while the preflight warned about a profile for a capability the build was never going to ask for. Both flags now, for the reason the scan's own comment gives: an entitlement without the define is a SyncedStore that reports itself unsupported on the device. Only an explicit true does it. Unset still means "the bytecode decides", which is what keeps an app that merely hands work to a nearby device from being given an iCloud entitlement its App ID may not carry. That half has no unit-test seam: the flag resolution lives inside build(), and the plist tests beside it drive static helpers rather than the build flow. I verified the placement instead -- both consumers, the entitlement block and injectToPlist(), run later in the same method -- and the plugin's 1938 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 24 ++++++++ .../com/codename1/builders/IPhoneBuilder.java | 19 +++++++ .../continuity/LocalContinuityTest.java | 56 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index eabc18ebd7c..9c61506e7be 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1259,6 +1259,30 @@ private static boolean restore(final AppState state, boolean[] outFailed) { failed = true; } outFailed[0] = !commit(state, applied || shown, failed); + // What the application did DURING the rebuild, which nothing else records. + // + // routeStackChanged() returns early while applyingRestore is set -- it has to, or the + // rebuild checkpoints and republishes the state it is applying, and the two devices + // bounce it back and forth. But the restored form's show callback is application code and + // may navigate: a screen that redirects to a newer one, an expired detail page sending + // the user to a list. Both notifications for that navigation land inside the window and + // are dropped, so the checkpoint records the routes that ARRIVED rather than the ones the + // user is on -- and a process death before the next one restores the screen the + // application redirected away from. + // + // AFTER commit(), and that ordering is the whole of it. commit() CLEARS the pending flag + // as part of settling the arrival, so asking before it set a flag that commit then wiped + // and the scheduled flush found nothing owed. Asked here, the flush that follows writes + // the stack the user actually has. + // + // Also after the lifecycle branch above, which returns: a callback that ended the session + // leaves the stack different from what was restored too -- clear() empties it -- and + // checkpointing there writes for a session that has just ended. + // + // Compared rather than assumed, so the ordinary arrival still costs no extra write. + if (!currentRoutes().equals(routes)) { + routeStackChanged(); + } return shown; } 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 0e62c41d7d6..895cc991106 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 @@ -4148,6 +4148,25 @@ public void usesClassMethod(String cls, String method) { // Not gated on the sync opt-out below: the store reports its own availability at // runtime from whether the entitlement actually granted one, and the continuation half // needs these symbols regardless. + // An explicit ios.continuity.sync=true is a DECLARATION, not only a veto. The hint + // documents itself as "set true to say so explicitly", and the signing preflight + // already reads it exactly that way -- it is how a project says it wants the store + // without that check having to read bytecode. The build ignored it unless the scan + // had already found the package, so a project that says so and whose usage the scan + // cannot see got neither the entitlement nor the define, while the preflight warned + // about a profile for a capability the build was never going to ask for. + // + // Both flags, because the scan sets both for the same reason its own comment gives: + // the store needs the native define as well as the entitlement, and an entitlement + // without the define is a SyncedStore that reports itself unsupported on a device. + // + // Only an explicit true does this. Unset still means "the bytecode decides", which is + // what keeps an app that merely hands work to a nearby device from being handed an + // iCloud entitlement its App ID may not carry. + if ("true".equals(request.getArg("ios.continuity.sync", null))) { + usesContinuity = true; + usesContinuitySync = true; + } if (usesContinuity) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_CONTINUITY", "#define CN1_USE_CONTINUITY"); } 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 index c6f32da1904..c779223b81b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4572,6 +4572,62 @@ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() { + "about to happen has nothing to deliver"); } + /** + * A redirect performed by the restored form's show callback is what gets checkpointed. + * + *

routeStackChanged() returns early while a restore is being applied -- it has to, or the + * rebuild checkpoints and republishes the state it is applying and the two devices bounce it + * back and forth. But the restored form's show callback is application code and may navigate: + * a screen that redirects to a newer one, an expired detail page sending the user to a list. + * Both notifications for that navigation land inside the window and are dropped, so the + * checkpoint recorded the routes that ARRIVED instead of the ones the user is on -- and a + * process death before the next one restores the screen the application redirected away + * from.

+ */ + @EdtTest + public void aRedirectDuringTheRebuildIsWhatGetsCheckpointed() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + final boolean[] redirected = new boolean[1]; + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + if ("/orders/17".equals(url)) { + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + if (!redirected[0]) { + // "That order is gone -- here is the list instead." + redirected[0] = true; + Navigation.navigate("/orders"); + } + } + }); + } + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(370L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + flushSerialCalls(); + assertTrue(redirected[0], "the show callback never redirected, so this tests nothing"); + + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, "nothing was stored at all"); + assertTrue(stored.getRoutes().contains("/orders"), + "the checkpoint kept the routes that ARRIVED (" + stored.getRoutes() + ") " + + "rather than the ones the application redirected to, so a process " + + "death restores the screen it sent the user away from"); + } finally { + Navigation.setDispatcher(null); + } + } + /** * An unrecognised relay object is a failed read; a bare empty one is still an empty relay. * From 1f1aaf2ca311369a032e2925ab2829c2fc457b89 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:37:54 +0300 Subject: [PATCH 112/140] Drop the arrival this class is holding when disable() comes before any enable() The door my own parking change opened, and the symmetry I missed when I closed the same one for clear(). Callback.decide() parks an arrival that reaches the seam before the application has chosen -- a synced-store listener installs that seam without enabling continuity -- so by the time a logged-out app says "off" there can be a copy here as well as at the port. disable()'s early return drained only the port's, and enable() drains this slot on purpose, so the login restored a payload and routes that arrived before the application said it wanted none. disable() documents the opposite. The full path below already clears it as part of ending the session; the early return leaves before reaching that, which is the whole of the difference between the two. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 10 +++++ .../continuity/LocalContinuityTest.java | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 9c61506e7be..7fee3de808b 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -403,6 +403,16 @@ public static void disable() { // is recorded -- so the callback claims and drops it, which is what disable() says // happens to arriving states. Same route as the one already taken by a state that // arrives after this returns, rather than a second mechanism doing the same job. + // + // THIS class's held arrival goes with it. Callback.decide() parks an arrival that + // reaches the seam before the application has chosen -- a synced-store listener + // installs that seam without enabling continuity -- so by the time a logged-out app + // says "off" there can be a copy here as well as at the port. Draining only the + // port's left this one in the slot, and enable() drains that slot on purpose, so the + // login restored a payload and routes that arrived before the application said it + // wanted none. The path below clears it as part of ending the session; this one + // returns before reaching that, which is the whole of the difference between them. + parked = null; installCallback(true); return; } 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 index c779223b81b..73200da61dc 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4875,6 +4875,50 @@ public void run() { } } + /** + * An arrival this class is holding is dropped by a disable() that comes before any enable(). + * + *

The sibling of the port-held case, and the door my own parking change opened. + * Callback.decide() parks an arrival that reaches the seam before the application has chosen + * -- a synced-store listener installs that seam without enabling continuity -- so a logged-out + * app saying "off" can have a copy here as well as at the port. Draining only the port's left + * this one in the slot, and enable() drains that slot on purpose, so the login restored a + * payload and routes that arrived before the application said it wanted none.

+ */ + @EdtTest + public void anArrivalThisClassIsHoldingIsDroppedByAFirstDisable() { + HoldingBridge holding = new HoldingBridge(); + Continuity.setBridge(holding); + SyncedStoreListener listener = new SyncedStoreListener() { + public void storeChanged() { + } + }; + try { + // A seam without continuity, which is what parks an arrival here. + SyncedStore.addChangeListener(listener); + Map info = StateCodec.toMap(fromElsewhere("before the app chose", 380L)); + ContinuityCallback c = Continuity.callbackForTest(); + c.continuationReceived(Continuity.getActivityType(), info); + flushSerialCalls(); + + // "Not while I am logged out." + Continuity.disable(); + + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + for (int i = 0; i < 20 && provider.restored == null; i++) { + pause(50L); + flushSerialCalls(); + } + assertNull(provider.restored, + "an arrival this class was holding survived the disable() and was restored by " + + "the enable() that came with the login, though disable() documents " + + "that arriving states are ignored"); + } finally { + SyncedStore.removeChangeListener(listener); + } + } + /** * A cold-launch arrival the port is already holding is dropped by the first disable(). * From 8b44659b47fb1e8db74849a6514ef8c399546d7e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:47:52 +0300 Subject: [PATCH 113/140] Hoist the disable() clear above the branch, for PMD and for clarity The previous commit put `parked = null` inside `if (!enabled)`, which is check-a-static-then-assign-a-static and trips the forbidden PMD rule NonThreadSafeSingleton. Both paths want it -- the full one clears the slot a few lines further down anyway -- so it is hoisted above the split rather than suppressed: one statement, said once, and the shape the rule objects to is gone. Recording the process failure rather than only the fix: I ran the quality gate and the commit in one chained command, so the push went out while the gate was reporting a violation. The gate's exit code has to be read before the commit, not beside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/continuity/Continuity.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 7fee3de808b..ef561f208b8 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -389,6 +389,16 @@ public static void disable() { // cold-launch arrival: declined, retained by the port, and delivered by the enable() // that came with the login. Saying "no" before saying anything else is still saying it. applicationHasChosen = true; + // THIS class's held arrival, on BOTH paths and therefore before the split. + // + // Callback.decide() parks an arrival that reaches the seam before the application has + // chosen -- a synced-store listener installs that seam without enabling continuity -- so + // by the time a logged-out app says "off" there can be a copy here as well as at the + // port. The early return below drained only the port's, and enable() drains this slot on + // purpose, so the login restored a payload and routes that arrived before the application + // said it wanted none. The full path clears it too, a few lines down; hoisting it here + // covers both and says once that a disable() drops what is held. + parked = null; if (!enabled) { // A callback is installed even though nothing is being turned off, and it is the only // way to reach an arrival that is already waiting. iOS parks a cold-launch Handoff @@ -412,7 +422,6 @@ public static void disable() { // login restored a payload and routes that arrived before the application said it // wanted none. The path below clears it as part of ending the session; this one // returns before reaching that, which is the whole of the difference between them. - parked = null; installCallback(true); return; } From 285561b401339c78b53ef3f7a115341dc3c6537e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:07:09 +0300 Subject: [PATCH 114/140] Read the arriving document before telling the port it was taken ContinuityCallback lets a port call from any thread and says nothing about the map outliving the call, so a port that recycles one buffer per arrival -- or clears it once the call returns -- is within its rights. The off-EDT branch queued only the reference and had ALREADY told that port the activity was claimed, so the decision read whatever the bridge had put there since: a different state, or none at all. Read into an AppState before returning instead. fromMap() detaches it completely, which is why no copy helper is needed here -- setPayload deep-copies, the routes are rebuilt into a new list, and everything else a payload may hold is immutable -- so what is queued is the framework's own object. The claim gets honest with it, which is the part worth having: a document that yields no state is now declined rather than claimed, because nothing was taken. The test drives a bridge that empties and refills its map the instant the call returns, and checks the payload that reaches the provider is the one that was handed over. Without the fix nothing is delivered at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 39 +++++++++++++- .../continuity/LocalContinuityTest.java | 51 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index ef561f208b8..e886e894532 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -3310,11 +3310,28 @@ public boolean continuationReceived(String activityType, Map use // So the framework takes it and holds it itself. decide() parks an arrival that comes // before the application has chosen, and enable() drains that slot -- the retention // that used to be borrowed from the port now lives where the state does. - final Map info = userInfo; + // READ NOW, not when the decision runs. Queuing the caller's map keeps a reference + // to something the bridge owns: ContinuityCallback lets a port call from any thread + // and says nothing about the map outliving the call, so a port that recycles one + // buffer per arrival -- or clears it once this returns -- has the decision reading + // different contents than the ones it was handed. And this branch has ALREADY told + // the port the activity was claimed, so what gets restored is a different state, or + // none at all. + // + // fromMap() detaches it completely, which is why no copy helper is needed here: + // setPayload deep-copies, the routes are rebuilt into a new list, and everything else + // a payload may hold is immutable. What is queued is the framework's own object. + // + // The claim gets honest with it: a document that yields no state is declined rather + // than claimed, because nothing was taken. + final AppState arriving = StateCodec.fromMap(userInfo); + if (arriving == null) { + return false; + } Display.getInstance().callSerially(new Runnable() { @Override public void run() { - decide(info, arrivedIn); + decide(arriving, arrivedIn); } }); return true; @@ -3344,6 +3361,24 @@ private boolean decide(Map userInfo, int arrivedIn) { if (state == null) { return false; } + return decide(state, arrivedIn); + } + + /// The same decision once the document is a state of this framework's own. + /// + /// Re-asks the questions the caller already asked rather than trusting them: on the queued + /// path they were answered on another thread, and enable(), disable() or clear() can have + /// run in between. + private boolean decide(AppState state, int arrivedIn) { + if (lifecycle != arrivedIn) { + return true; + } + if (discardHeldArrival) { + return true; + } + if (!enabled && applicationHasChosen) { + return true; + } if (!enabled) { // NOTHING SAID YET, and the arrival is held HERE rather than left with the port. // 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 index 73200da61dc..1a89e064039 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4812,6 +4812,57 @@ public void run() { + "restart as already seen"); } + /** + * A bridge that reuses its map after the call does not change what gets restored. + * + *

ContinuityCallback lets a port call from any thread and says nothing about the map + * outliving the call, so a port that recycles one buffer per arrival -- or clears it once the + * call returns -- is within its rights. The off-EDT branch had already told that port the + * activity was CLAIMED while queuing only the reference, so the decision read whatever the + * bridge had put there since: a different state, or none.

+ */ + @EdtTest + public void aBridgeThatReusesItsMapDoesNotChangeWhatIsRestored() { + Continuity.enable(); + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + final ContinuityCallback callback = Continuity.callbackForTest(); + + // The map the "port" hands over, and then reuses for something else entirely. + final Map reused = + StateCodec.toMap(fromElsewhere("what was actually sent", 390L)); + final java.util.concurrent.CountDownLatch returned = + new java.util.concurrent.CountDownLatch(1); + Display.getInstance().startThread(new Runnable() { + public void run() { + callback.continuationReceived(Continuity.getActivityType(), reused); + // Recycled the instant the call returns, exactly as a pooling bridge would. + reused.clear(); + reused.put("device", "somebody-else"); + reused.put("seq", "999"); + returned.countDown(); + } + }, "continuity recycling bridge").start(); + + for (int i = 0; i < 40 && returned.getCount() > 0; i++) { + pause(50L); + flushSerialCalls(); + } + assertEquals(0L, returned.getCount(), "the bridge thread never returned"); + for (int i = 0; i < 20 && provider.restored == null; i++) { + pause(50L); + flushSerialCalls(); + } + + assertNotNull(provider.restored, + "the claimed arrival was never delivered -- the queued decision read a map the " + + "bridge had already emptied"); + assertEquals("what was actually sent", provider.restored.get("note"), + "the payload delivered was not the one handed over, because the decision read " + + "the bridge's buffer after it had been recycled"); + } + /** * A background arrival before enable() is claimed AND kept, and the enable() delivers it. * From a3878e36544eabd97e4b5d6dee6acc2e6ad61d58 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:22:48 +0300 Subject: [PATCH 115/140] Gate the restore's stack rollback the way its display rollback is gated I gated the display half of this rollback last round and left the stack half unconditional, which made the two disagree. A show callback that navigates somewhere of its own and then throws -- or navigates and has a later listener throw -- has already changed both, so erasing the stack while leaving that screen up describes a place the user is not: back() then works on a history that does not include what is in front of them. Same rule as the ordinary navigations already use, and for the same reason: whatever ran later and changed the stack meant to, and it wins. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/router/Navigation.java | 14 +++++- .../continuity/LocalContinuityTest.java | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index e05697d5592..acd1239771c 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -360,8 +360,18 @@ public static boolean restoreStack(List paths) { // run the reverse transition into a screen they have not seen yet. top.show(); } catch (RuntimeException e) { - stack.clear(); - stack.addAll(previous); + // The STACK, and only while it is still exactly what this method installed. The + // display test below was added first and left this one unconditional, which made the + // two disagree: a show callback that navigates somewhere of its own and then throws + // -- or that navigates and a later listener throws -- has already changed both, so + // erasing the stack while leaving its screen up describes a place the user is not. + // + // Same rule as the ordinary navigations use, for the same reason: whatever ran later + // and changed the stack meant to, and it wins. + if (rebuilt.equals(stack)) { + stack.clear(); + stack.addAll(previous); + } // And the screen with it -- but ONLY when the screen still showing is the one this // method put up. show() rather than showBack(): the user is not going back, an // attempt that failed is being undone. 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 index 1a89e064039..cfa663ff909 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4342,6 +4342,56 @@ public void aNestedPayloadCollectionCannotBeMutatedByTheApplication() { "the framework's snapshot changed"); } + /** + * A screen the restored form's callback navigated to keeps its stack entry when a later + * listener throws. + * + *

The display half of this rollback was gated first and the stack half left + * unconditional, which made the two disagree: a show callback that navigates somewhere of + * its own and then throws has already changed both, so erasing the stack while leaving its + * screen up describes a place the user is not -- back() then works on a history that does + * not include what is in front of them.

+ */ + @EdtTest + public void aScreenTheCallbackNavigatedToKeepsItsStackEntry() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + if ("/orders/17".equals(url)) { + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + if (Navigation.getCurrent() == null + || !"/replacement".equals(Navigation.getCurrent().getPath())) { + Navigation.navigate("/replacement"); + throw new IllegalStateException("and then this failed"); + } + } + }); + } + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device") + .setSequence(400L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertNotNull(Navigation.getCurrent(), "the stack was emptied altogether"); + assertEquals("/replacement", Navigation.getCurrent().getPath(), + "the rollback erased the screen the callback navigated to, so back() works " + + "on a history that does not include what the user is looking at; " + + "top is " + Navigation.getCurrent().getPath()); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A login form opened by the restored form's own show callback survives. * From a0c90120fab8db83a64ecc4254e8a1ae52ebd50c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:49:39 +0300 Subject: [PATCH 116/140] Apply the filtered routes where they are filtered, not one exit later A state carrying a good payload and nothing storable beside it takes the payload-only return, and that return reached commit() before the filtered set was applied. persist() then threw on the original oversized route every time, so the arrival stayed parked, was re-applied on every retry, and held every relay publication behind it -- after the provider had already taken the payload. This is the second time this one reconciliation was applied to one path and not the other, so it stops being a statement placed near a path and becomes the statement immediately after the filter it belongs to. Every exit below now carries it, including ones nobody has thought of yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 27 ++++++++---- .../continuity/LocalContinuityTest.java | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e886e894532..e0f6fd76fd6 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1145,6 +1145,24 @@ private static boolean restore(final AppState state, boolean[] outFailed) { } } List routes = usableRoutes(state.getRoutes()); + if (routes.size() != state.getRoutes().size()) { + // The FILTERED set is what gets committed, and it is applied HERE so that every exit + // below carries it. usableRoutes() dropped a route this device cannot store, and only + // the copy handed to restoreStack() had it removed -- so commit() persisted the + // original, externalize() threw on the oversized string every time, and the arrival + // stayed parked: re-applied on every retry, with every relay publication held behind + // it, for ever. + // + // It sat further down and the payload-only return below reached commit() before it, + // so a state whose routes were ALL unusable -- a valid payload with nothing storable + // beside it -- still committed the originals and failed in exactly that way. That is + // the second time this reconciliation was applied to one path and not the other, + // which is why it is now the statement immediately after the filter it belongs to. + // + // Unchecked because these routes have already passed the very check that produced + // this list. + state.setRoutesUnchecked(routes); + } if (routes.isEmpty()) { // Payload-only restoration, which is what an app that does not use @Route gets. The // provider has been given everything there is, and false is deliberate: it is what @@ -1240,15 +1258,6 @@ private static boolean restore(final AppState state, boolean[] outFailed) { outFailed[0] = true; return false; } - if (routes.size() != state.getRoutes().size()) { - // The FILTERED set is what gets committed. usableRoutes() dropped a route this device - // cannot store, and only the copy handed to restoreStack() had it removed -- so - // commit() went on to persist the original, externalize() threw on the oversized - // string every time, and the arrival stayed parked: re-applied on every retry, with - // every relay publication held behind it, for ever. Unchecked because these routes - // have already passed the very check that produced this list. - state.setRoutesUnchecked(routes); - } if (routesThrew) { // A THROW, which is a different thing from routes that would not rebuild, and the two // were collapsed here. The reasoning below is about the orderly case: this build no 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 index cfa663ff909..1122cec77b3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4535,6 +4535,47 @@ public Form dispatch(String url) { } } + /** + * A state whose routes are ALL unusable still commits, on the payload-only path. + * + *

The sibling of the test below, and the exit the reconciliation did not cover. + * usableRoutes() drops every route this device cannot store, so a state carrying a good + * payload and nothing storable beside it takes the payload-only return -- which reached + * commit() before the filtered set was applied. persist() then threw on the original + * oversized route every time, so the arrival stayed parked, was re-applied on every retry, + * and held every relay publication behind it.

+ */ + @EdtTest + public void aStateWhoseRoutesAreAllUnusableStillCommits() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + StringBuilder huge = new StringBuilder("/"); + for (int i = 0; i < 70000; i++) { + huge.append('x'); + } + Map payload = new HashMap(); + payload.put("draft", "worth keeping"); + AppState arriving = new AppState() + .setPayload(payload) + .setDeviceId("some-other-device") + .setSequence(410L) + .setTimestamp(System.currentTimeMillis()); + // Unchecked, because a remote document is accepted unchecked on purpose -- which is how + // an unstorable route reaches this device at all. + arriving.setRoutesUnchecked(java.util.Arrays.asList(huge.toString())); + + Continuity.restore(arriving); + flushSerialCalls(); + + assertEquals("worth keeping", provider.restored.get("draft"), + "the payload never reached the provider, so this test is about nothing"); + assertNotNull(Continuity.readSeenForTest().get("some-other-device"), + "the arrival was never acknowledged: commit() persisted the oversized route the " + + "filter had already dropped, so it stays parked, is re-applied on every " + + "retry, and holds every relay publication behind it"); + } + /** * A route this device cannot store is dropped from what gets COMMITTED too, not only from * what gets rebuilt. From b07b136a2bc3449caa5ebfb1117dc5ca32dd45c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:14:24 +0300 Subject: [PATCH 117/140] Let an offer replaced by another device come back The slot holds one arrival and that is right: getRestorableState() answers with a state, and an application that has not dealt with the last one does not want a queue growing behind it. Replacing it is right too when the two come from the SAME device -- that is supersession, and the newer sequence is the one worth showing. Two different devices are not that. With automatic restoration off, both can be dispatched before the application calls restore(), and the second simply overwrote the first. That would be survivable if the first could come back, and it could not: its (origin, sequence) went into the in-memory map at admission, so a redelivery in the same run was refused as already seen. Recorded as handled, then dropped, and gone for the rest of the process. So the mark goes with it. Only the in-memory one -- durableSeen is written when a state COMPLETES and this one never did, so nothing durable claims it -- and only while it still names the dropped state, so a newer mark for that origin is left alone. Kept as one slot rather than a queue per origin, which is where the report left the choice open: the single slot is the documented shape of getRestorableState(), and the defect was that dropping was irreversible, not that dropping happens. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 36 +++++++++++- .../continuity/LocalContinuityTest.java | 55 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e0f6fd76fd6..0166b3b86fc 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -2462,10 +2462,42 @@ private static void dispatch(AppState state) { // // The answer was thrown away here. restore(state) has always known the // difference; this call site simply did not ask. - parked = state; + placeOnOffer(state); } } else { - parked = state; + placeOnOffer(state); + } + } + + /// Puts a state on offer, without silently losing the one it replaces. + /// + /// The slot holds one arrival, which is right: `getRestorableState()` answers with a state, + /// and an application that has not dealt with the last one does not want a queue growing + /// behind it. Replacement is right too when the two come from the SAME device -- that is + /// supersession, and the newer sequence is the one worth showing. + /// + /// Two different devices are not that. With automatic restoration off, both can be dispatched + /// before the application calls restore(), and the second simply overwrote the first -- which + /// would be survivable if the first could come back, and it could not: its (origin, sequence) + /// is in the in-memory map from admission, so a redelivery in the same run is refused as + /// already seen. Recorded as handled and then dropped. + /// + /// So the mark goes with it. Only the in-memory one: durableSeen is written when a state + /// COMPLETES, and this one never did, so nothing durable claims it. Only when it still names + /// this state, so a newer mark for that origin is left alone. + private static void placeOnOffer(AppState state) { + AppState replaced = parked; + parked = state; + if (replaced == null || replaced == state) { //NOPMD CompareObjectsWithEquals + return; + } + String origin = replaced.getDeviceId(); + if (origin == null || origin.length() == 0 || origin.equals(state.getDeviceId())) { + return; + } + Long mark = lastSeen.get(origin); + if (mark != null && mark.longValue() == replaced.getSequence()) { + lastSeen.remove(origin); } } 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 index 1122cec77b3..0e899006954 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4535,6 +4535,61 @@ public Form dispatch(String url) { } } + /** + * An offer replaced by one from ANOTHER device can be delivered again. + * + *

The slot holds one arrival, which is right, and replacing it is right when the two come + * from the same device -- that is supersession. Two different devices are not that: with + * automatic restoration off both can be dispatched before the application calls restore(), + * and the second overwrote the first. That would be survivable if the first could come back, + * and it could not: its (origin, sequence) went into the in-memory map at admission, so a + * redelivery in the same run was refused as already seen. Recorded as handled and then + * dropped.

+ */ + @EdtTest + public void anOfferReplacedByAnotherDeviceCanBeDeliveredAgain() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + AppState first = new AppState() + .setPayload(payloadWith("from the phone")) + .setDeviceId("phone").setSequence(1L) + .setTimestamp(System.currentTimeMillis()); + AppState second = new AppState() + .setPayload(payloadWith("from the tablet")) + .setDeviceId("tablet").setSequence(1L) + .setTimestamp(System.currentTimeMillis()); + + Continuity.deliver(first); + flushSerialCalls(); + Continuity.deliver(second); + flushSerialCalls(); + + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "nothing is on offer at all"); + assertEquals("tablet", onOffer.getDeviceId(), + "the newer arrival is not the one on offer"); + + // The phone's state was dropped from the slot. It must not ALSO be remembered as handled, + // or the relay and the port can never offer it again for the rest of the run. + assertNull(Continuity.readSeenForTest().get("phone"), + "a durable mark was left for a state that was never completed"); + Continuity.deliver(first); + flushSerialCalls(); + AppState back = Continuity.getRestorableState(); + assertNotNull(back, "the phone's state came back to nothing"); + assertEquals("phone", back.getDeviceId(), + "the phone's state was refused as already seen, though it was dropped without " + + "ever being handled -- so it is lost for the rest of the process"); + } + + private static Map payloadWith(String note) { + Map payload = new HashMap(); + payload.put("note", note); + return payload; + } + /** * A state whose routes are ALL unusable still commits, on the payload-only path. * From 4a3177ddc292b6a04a4aca512e0be90bcc6181d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:36:49 +0300 Subject: [PATCH 118/140] Put every offer through the one place that does the bookkeeping Last commit made a replaced offer recoverable and applied that at two of the FIVE places a state is put on offer. A listener that returns false for device A and then for device B before A is resolved goes through a third, so B replaced A with A's mark still recorded and A could never be offered again in that run -- the same defect, one call site over. All five go through placeOnOffer() now: the cold-launch hold before the event thread exists, the wait for a first window, the listener's hold, the pre-enable hold, and the deferred-restore hold. Each is a place where a second arrival can find one already waiting, which is why fixing them one at a time kept missing one. The test seam goes through it too, so a test that parks twice exercises what the framework actually does rather than a shortcut past it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 16 +++++-- .../continuity/LocalContinuityTest.java | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 0166b3b86fc..dce7ca804db 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -2154,7 +2154,7 @@ static void deliver(final AppState state) { if (!Display.isInitialized()) { // No event thread yet, so there is nothing to marshal to and nothing running that // could be racing this. Held for the EDT that is about to start. - parked = state; + placeOnOffer(state); return; } // The generation the arrival BELONGS to, read here rather than on the event thread, @@ -2420,7 +2420,7 @@ private static void dispatch(AppState state) { // call that meaning is documented to make. Whichever the application meant, the // hold ends when it says so rather than being guessed at here. if (!isAlreadyActedOn(state)) { - parked = state; + placeOnOffer(state); } return; } @@ -2471,6 +2471,12 @@ private static void dispatch(AppState state) { /// Puts a state on offer, without silently losing the one it replaces. /// + /// EVERY path that offers a state comes through here, and that is the point rather than a + /// tidiness: the bookkeeping was added at two of the five and a listener that declines two + /// arrivals in a row still lost the first. The others are a cold-launch hold, the wait for a + /// first window, and the pre-enable hold -- each of them a place where a second arrival can + /// find one already waiting. + /// /// The slot holds one arrival, which is right: `getRestorableState()` answers with a state, /// and an application that has not dealt with the last one does not want a queue growing /// behind it. Replacement is right too when the two come from the SAME device -- that is @@ -2538,7 +2544,7 @@ private static boolean isAlreadyActedOn(AppState state) { /// state: it sleeps, asks the event thread whether a window has appeared, and hands the /// decision back to it. private static void park(AppState state) { - parked = state; + placeOnOffer(state); if (waitingForWindow) { return; } @@ -2872,7 +2878,7 @@ static ContinuityCallback callbackForTest() { /// Test seam: parks a state, as a cold-launch arrival with no form yet does. static void parkForTest(AppState state) { - parked = state; + placeOnOffer(state); } /// Test seam: the cold-launch drain, entered exactly where the waiter enters it. @@ -3435,7 +3441,7 @@ private boolean decide(AppState state, int arrivedIn) { // recovery the port used to provide, in the place that actually holds the state. // False is still returned for a port that is holding one too: the two copies // dedup on (origin, sequence) at admission. - parked = state; + placeOnOffer(state); return false; } deliver(state); 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 index 0e899006954..247d91dacbe 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4535,6 +4535,54 @@ public Form dispatch(String url) { } } + /** + * A listener that holds two arrivals in a row does not lose the first. + * + *

Returning false keeps an arrival on offer -- the documented prompt-then-restore pattern. + * Do it for device A and then for device B before A is resolved and B replaced A in the slot, + * with A's mark still recorded, so A could never be offered again in that run. The + * bookkeeping that makes a replacement recoverable was added at two of the five places a + * state is put on offer, and this is one of the three it missed.

+ */ + @EdtTest + public void aListenerHoldingTwoArrivalsDoesNotLoseTheFirst() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(true); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + // "I will prompt and call restore() when the user accepts." + return false; + } + }); + + AppState fromPhone = new AppState() + .setPayload(payloadWith("from the phone")) + .setDeviceId("phone").setSequence(1L) + .setTimestamp(System.currentTimeMillis()); + AppState fromTablet = new AppState() + .setPayload(payloadWith("from the tablet")) + .setDeviceId("tablet").setSequence(1L) + .setTimestamp(System.currentTimeMillis()); + + Continuity.deliver(fromPhone); + flushSerialCalls(); + Continuity.deliver(fromTablet); + flushSerialCalls(); + + assertNull(Continuity.readSeenForTest().get("phone"), + "a durable mark was left for a held state that was never completed"); + + // The phone's work comes round again -- another Handoff, or the next relay read. It has + // to be offerable, because nothing ever dealt with it. + Continuity.deliver(fromPhone); + flushSerialCalls(); + AppState back = Continuity.getRestorableState(); + assertNotNull(back, "the phone's held state came back to nothing"); + assertEquals("phone", back.getDeviceId(), + "the state the listener was holding for the phone was refused as already seen " + + "after the tablet's replaced it, so it is lost for the rest of the run"); + } + /** * An offer replaced by one from ANOTHER device can be delivered again. * From a25f362063ef0df990a744ec5844b2432c2ecadc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:11:27 +0300 Subject: [PATCH 119/140] Keep a local checkpoint whose restore failed on offer dispatch() already keeps an ARRIVAL whose restore failed, for the reason its own comment gives: a provider that throws is usually transient, so the state is worth holding for a retry. The application-driven restore() did not do the same for a state that came from STORAGE. So a cold start whose provider threw -- a dependency not up yet, which is the transient the whole failure branch exists for -- left the on-device checkpoint as the only copy, and the next navigation checkpointed the fallback screen over it. The draft the user was promised is gone at exactly the moment "restore, or else begin" is meant to protect it. A no-op when the state came from the slot, because placeOnOffer() returns immediately when asked to replace something with itself, so the arrival path is unchanged. It does hold relay publication until the application resolves the state, by retrying or acknowledging. That is the same hold a failed arrival already takes and for the same reason: what is on the relay is worth more than what this device would write over it while it cannot even load its own payload. Saying so here because it is a real cost, not a free win. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 17 +++++++ .../continuity/LocalContinuityTest.java | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index dce7ca804db..1c22424a8d6 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -991,6 +991,23 @@ public static boolean restore() { // admit() has already put the sequence in the live map, so nothing offers the state // again this run, and releasing the publication lets a checkpoint overwrite the // relay's only copy. The retry it was being kept for then has nothing to retry. + // + // ON OFFER, which is what keeps a state that came from STORAGE rather than from the + // slot. dispatch() already does this for an arrival whose restore failed; the + // application-driven path did not, so a cold start whose provider threw -- a + // dependency not up yet, which is the transient this whole failure branch exists for + // -- left the on-device checkpoint as the only copy, and the next navigation + // checkpointed the fallback screen over it. The draft the user was promised is then + // gone, at exactly the moment "restore, or else begin" was meant to protect it. + // + // A no-op when the state came from the slot: placeOnOffer() returns immediately when + // it is asked to replace something with itself. + // + // It does hold relay publication until the application resolves the state, by + // retrying or by acknowledging it. That is the same hold a failed arrival already + // takes and for the same reason -- what is on the relay is worth more than what this + // device would write over it while it cannot even load its own payload. + placeOnOffer(state); return shown; } // Released because the state was APPLIED, not because a form appeared. Gating this on 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 index 247d91dacbe..d86c9b8e11b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4535,6 +4535,55 @@ public Form dispatch(String url) { } } + /** + * A local checkpoint whose restore FAILED survives the next checkpoint. + * + *

dispatch() already keeps an arrival whose restore failed; the application-driven path + * did not. So a cold start whose provider threw -- a dependency not up yet, which is the + * transient this failure branch exists for -- left the on-device checkpoint as the only copy, + * and the next navigation checkpointed the fallback screen over it. The draft the user was + * promised is gone at exactly the moment "restore, or else begin" is meant to protect it.

+ */ + @EdtTest + public void aLocalCheckpointWhoseRestoreFailedSurvivesTheNextCheckpoint() { + // A provider that saves a draft, then refuses to restore it once, then answers empty -- + // an application whose own data has not loaded yet. + final boolean[] refuse = new boolean[] {false}; + final Map toSave = new HashMap(); + toSave.put("draft", "half a letter"); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + return new HashMap(toSave); + } + + public void restoreState(Map payload) { + if (refuse[0]) { + throw new IllegalStateException("the store is not open yet"); + } + } + }); + Continuity.checkpoint(); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), "the fixture stored nothing"); + + // The restore fails, and the application carries on with an empty provider. + refuse[0] = true; + toSave.clear(); + assertFalse(Continuity.restore(), "the refusing restore reported a shown form"); + flushSerialCalls(); + + // Ordinary work continues and checkpoints, as it must. + Continuity.checkpoint(); + flushSerialCalls(); + + AppState still = Continuity.getRestorableState(); + assertNotNull(still, "nothing is offered at all after the failed restore"); + assertEquals("half a letter", still.getPayload().get("draft"), + "the checkpoint that followed the failed restore overwrote the only copy of the " + + "payload, so the retry this failure path exists for has nothing left " + + "to retry"); + } + /** * A listener that holds two arrivals in a row does not lose the first. * From 65d0414c4d08be8e19a564a4237eb0d9f11538df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:41:59 +0300 Subject: [PATCH 120/140] Recheck expiry where the application hands a state back, and let a factory redirect win dispatch() and getRestorableState() both check maxAge and neither is the last word. The documented flow is that a listener returns false, puts a prompt in front of the user, and calls restore(state) when they accept -- and the deciding is exactly the time that passes. A state fresh when it was offered can be stale when it is taken, and an expired checkout or booking hold is precisely what maxAge exists to refuse. It is discarded the way getRestorableState() discards one, rather than left on offer to be handed back again: the slot is released and the publisher let go, because the hold existed for a state that will never be applied. Separately, a route FACTORY may redirect -- an expired detail page sending the user to a list -- and it does so before restoreStack() has installed anything, so the rebuild replaced both its stack entry and its screen. Its choice wins now, the same rule the rollback and the ordinary navigations already use. Returning false means Continuity treats the restore as showing nothing, and the reconciliation added earlier checkpoints the stack the factory left behind, so the two compose rather than needing a second mechanism. The factory test needed a second look: the first version had the factory answer null, which the empty-rebuild check already covers, so it passed with the new guard removed. It answers with a form now, which is what makes the rebuild non-empty and the guard the thing under test. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 17 ++++ .../src/com/codename1/router/Navigation.java | 15 ++++ .../continuity/LocalContinuityTest.java | 88 +++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1c22424a8d6..e61e9726098 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1097,6 +1097,23 @@ private static boolean restore(final AppState state, boolean[] outFailed) { if (state == null) { return false; } + if (isTooOld(state)) { + // Rechecked HERE, where the application hands one back. dispatch() and + // getRestorableState() both ask, and neither is the last word: the documented flow is + // that a listener returns false, puts a prompt in front of the user, and calls + // restore(state) when they accept -- and the deciding is exactly the time that + // passes. A state fresh when it was offered can be stale by the time it is taken, and + // an expired checkout or booking hold is precisely what maxAge exists to refuse. + // + // Discarded the way getRestorableState() discards one, rather than left on offer to + // be handed back again: the slot is released and the publisher let go, because the + // hold existed for a state that is now never going to be applied. + if (parked == state) { //NOPMD CompareObjectsWithEquals + parked = null; + } + startPublisher(); + return false; + } if (isFromAnEndedSession(state)) { // Delivered in a session that has since ended. The only way to be holding one of // these is the documented prompt-then-restore pattern -- a listener returns false, diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index acd1239771c..393bca1f5cd 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -298,6 +298,9 @@ public static boolean restoreStack(List paths) { if (d == null || paths == null || paths.isEmpty()) { return false; } + // The stack as it stands BEFORE any factory runs, so a factory that navigates can be + // told apart from one that does not. See the check after the loop. + List beforeDispatch = new ArrayList(stack); List rebuilt = new ArrayList(); for (String path : paths) { if (sessionEnded()) { @@ -328,6 +331,18 @@ public static boolean restoreStack(List paths) { rebuilt.add(new NavigationEntry(path, f)); } } + if (!beforeDispatch.equals(stack)) { + // A FACTORY navigated. A route factory is application code and may redirect -- an + // expired detail page sending the user to a list, a screen that has moved -- and it + // does so before this method has installed anything, so the rebuild that follows + // would replace both its stack entry and its screen with the ones being restored. + // + // Its choice wins, which is the same rule the rollback below and the ordinary + // navigations already use: whatever ran later and changed the stack meant to. This + // returns false, so Continuity treats the restore as one that showed nothing, and the + // reconciliation there checkpoints the stack the factory actually left behind. + return false; + } if (rebuilt.isEmpty() || sessionEnded()) { // Asked again, because the loop tests before each factory and the LAST one has no // "next" iteration to be stopped by. Showing here would put the signed-out account's 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 index d86c9b8e11b..314983eb585 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4535,6 +4535,94 @@ public Form dispatch(String url) { } } + /** + * A state that expires while the user is deciding is refused when the listener hands it back. + * + *

dispatch() and getRestorableState() both check maxAge, and neither is the last word. The + * documented flow is that a listener returns false, prompts, and calls restore(state) when the + * user accepts -- and the deciding is exactly the time that passes. An expired checkout or + * booking hold is what maxAge exists to refuse, and it was applied, persisted and acknowledged + * because this door did not ask.

+ */ + @EdtTest + public void aStateThatExpiredWhileTheUserDecidedIsRefused() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + try { + AppState offered = new AppState() + .setPayload(payloadWith("a checkout hold")) + .setDeviceId("some-other-device").setSequence(420L) + .setTimestamp(System.currentTimeMillis()); + + // Fresh when it arrives. + Continuity.setMaxAge(60000L); + Continuity.deliver(offered); + flushSerialCalls(); + assertNotNull(Continuity.getRestorableState(), "the arrival was not offered at all"); + + // The user takes their time, and the hold expires while the prompt is up. + Continuity.setMaxAge(1L); + pause(30L); + + assertFalse(Continuity.restore(offered), "an expired state reported a shown form"); + flushSerialCalls(); + assertNull(provider.restored, + "an expired checkout hold was handed to the provider, applied and " + + "acknowledged, though maxAge exists to refuse exactly that"); + assertNull(Continuity.getRestorableState(), + "the expired state is still on offer, so the application will be handed it " + + "again"); + } finally { + Continuity.setMaxAge(0L); + } + } + + /** + * A redirect started inside a route FACTORY wins over the stack being rebuilt. + * + *

A factory is application code and may redirect -- an expired detail page sending the user + * to a list. It does so before restoreStack() has installed anything, so the rebuild replaced + * both its stack entry and its screen with the ones being restored. The show-callback twin of + * this was fixed earlier; this one happens a step sooner.

+ */ + @EdtTest + public void aRedirectStartedInsideAFactoryWins() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + final boolean[] redirected = new boolean[1]; + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + if ("/orders/17".equals(url) && !redirected[0]) { + redirected[0] = true; + // "That order is gone." Redirect from inside the factory itself -- and still + // answer with a form, because a factory that returns null is already covered + // by the empty-rebuild check and would make this test pass either way. That + // is what a first version of it did. + Navigation.navigate("/orders"); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device").setSequence(430L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + assertTrue(redirected[0], "the factory never redirected, so this tests nothing"); + + assertNotNull(Navigation.getCurrent(), "the stack was left empty"); + assertEquals("/orders", Navigation.getCurrent().getPath(), + "the rebuild replaced the screen the factory redirected to; top is " + + Navigation.getCurrent().getPath()); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A local checkpoint whose restore FAILED survives the next checkpoint. * From dd5abcbdda80c567a5910d91e0ac124dad10a9f3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:13:31 +0300 Subject: [PATCH 121/140] Stop the rebuild at the redirect, and settle the arrival that caused it Both of these are the cost of the factory-redirect guard I added an hour ago, and both are things that guard should have done from the start. The stack comparison sat after the whole loop, so every later factory still constructed its screen and touched whatever the application keeps behind it -- an unavailable parent redirecting to a safe list while its child factories go on reading the record that is unavailable -- and all of it was then discarded in favour of the redirect. It is asked per iteration now, and once more after the loop because the last factory has no next iteration to be stopped by. That is the same pairing the session check beside it already uses, which is what it should have been copied from. And restoreStack() returning false read as "nothing happened", so a route-only arrival took the failure branch: parked, holding relay publication, and offered again after every launch to redirect again. The application DID handle it, by going somewhere else. Continuity compares the live stack with what it was before the rebuild, so a redirect from a factory OR from a show callback settles the arrival and checkpoints where the user actually ended up. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 18 ++++- .../src/com/codename1/router/Navigation.java | 28 +++++-- .../continuity/LocalContinuityTest.java | 80 +++++++++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e61e9726098..6d7c0821ea7 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1224,6 +1224,15 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // form alone -- the caller decides where to go next -- so undoing the history of a // cancelled restore left the signed-out account's SCREEN in front of the user. com.codename1.ui.Form beforeRestore = Display.getInstance().getCurrent(); + // The live stack BEFORE the rebuild, so application code that navigates during it can be + // recognised afterwards. A route factory or a show callback may redirect -- an expired + // detail page sending the user to a list -- and restoreStack() then returns false because + // it deliberately did not install its own screens over that choice. + // + // Without this, false reads as "nothing happened": a route-only arrival takes the + // failure branch below, is parked, holds relay publication, and is offered again after + // every launch to redirect again. The application DID handle it, by going somewhere else. + List routesBefore = currentRoutes(); boolean shown; boolean routesThrew = false; applyingRestore = true; @@ -1306,7 +1315,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // restored screen, and the next navigation overwriting both. Restoring twice is a // smaller harm than losing the work. failed = true; - } else if (!shown && !applied) { + } else if (!shown && !applied && currentRoutes().equals(routesBefore)) { // Routes were named, none could be rebuilt, and nothing else in the state applied // either -- an attempt that failed outright, so it stays on the relay for a launch // that can use it. @@ -1320,7 +1329,12 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // payload already worked on this one. failed = true; } - outFailed[0] = !commit(state, applied || shown, failed); + // Handled ALSO when the application navigated during the rebuild. It chose where to go + // because of this arrival, which is as much an answer as showing the restored screen + // would have been -- and leaving it unsettled parks it, holds publication, and offers it + // again after every launch. + outFailed[0] = !commit(state, + applied || shown || !currentRoutes().equals(routesBefore), failed); // What the application did DURING the rebuild, which nothing else records. // // routeStackChanged() returns early while applyingRestore is set -- it has to, or the diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 393bca1f5cd..af9fc3f0270 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -303,6 +303,15 @@ public static boolean restoreStack(List paths) { List beforeDispatch = new ArrayList(stack); List rebuilt = new ArrayList(); for (String path : paths) { + if (!beforeDispatch.equals(stack)) { + // A FACTORY navigated, and the rebuild stops here rather than at the end. Every + // later factory would still construct its screen and read or write whatever the + // application keeps behind it -- an unavailable parent redirecting to a safe list + // while its child factories go on touching the record that is unavailable -- and + // all of it would then be discarded in favour of the redirect. Asked per + // iteration for the same reason the session check beside it is. + return false; + } if (sessionEnded()) { // A factory ended the continuity session -- it found the account signed out, // which is exactly the decision a route factory is entitled to make. Every later @@ -332,15 +341,18 @@ public static boolean restoreStack(List paths) { } } if (!beforeDispatch.equals(stack)) { - // A FACTORY navigated. A route factory is application code and may redirect -- an - // expired detail page sending the user to a list, a screen that has moved -- and it - // does so before this method has installed anything, so the rebuild that follows - // would replace both its stack entry and its screen with the ones being restored. + // Asked again, because the loop tests before each factory and the LAST one has no + // next iteration to be stopped by -- the same pairing the session check uses. + // + // A route factory is application code and may redirect: an expired detail page + // sending the user to a list, a screen that has moved. It does so before this method + // has installed anything, so the rebuild would replace both its stack entry and its + // screen. Its choice wins, which is the rule the rollback below and the ordinary + // navigations already use. // - // Its choice wins, which is the same rule the rollback below and the ordinary - // navigations already use: whatever ran later and changed the stack meant to. This - // returns false, so Continuity treats the restore as one that showed nothing, and the - // reconciliation there checkpoints the stack the factory actually left behind. + // Continuity notices the stack moved and settles the arrival rather than treating it + // as one that failed -- see the note there. It also checkpoints what the factory left + // behind, so the redirect survives a process death. return false; } if (rebuilt.isEmpty() || sessionEnded()) { 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 index 314983eb585..bb763d9b355 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4578,6 +4578,86 @@ public void aStateThatExpiredWhileTheUserDecidedIsRefused() { } } + /** + * A route-only arrival whose factory redirects is settled, not parked for ever. + * + *

restoreStack() returns false when it leaves a factory's redirect alone, and false used + * to read as "nothing happened": a state with routes and no payload took the failure branch, + * was parked, held relay publication, and was offered again after every launch to redirect + * again. The application DID handle it -- by going somewhere else.

+ */ + @EdtTest + public void aRouteOnlyArrivalWhoseFactoryRedirectsIsSettled() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + final boolean[] redirected = new boolean[1]; + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + if ("/orders/17".equals(url) && !redirected[0]) { + redirected[0] = true; + Navigation.navigate("/orders"); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + AppState routeOnly = new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device").setSequence(440L) + .setTimestamp(System.currentTimeMillis()); + Continuity.restore(routeOnly); + flushSerialCalls(); + assertTrue(redirected[0], "the factory never redirected, so this tests nothing"); + + assertNotNull(Continuity.readSeenForTest().get("some-other-device"), + "the arrival was never settled, so it stays parked, holds relay publication, " + + "and is offered again after every launch to redirect again"); + } finally { + Navigation.setDispatcher(null); + } + } + + /** + * A factory that redirects stops the rebuild before the next factory runs. + * + *

The comparison sat after the whole loop, so every later factory still constructed its + * screen and touched whatever the application keeps behind it -- an unavailable parent + * redirecting to a safe list while its child factories go on reading the record that is + * unavailable -- and all of it was then discarded in favour of the redirect.

+ */ + @EdtTest + public void aFactoryRedirectStopsTheRebuildBeforeTheNextFactoryRuns() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + final List built = new ArrayList(); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + built.add(url); + if ("/orders/17".equals(url) && built.size() == 1) { + Navigation.navigate("/orders"); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17", "/orders/17/pay")) + .setDeviceId("some-other-device").setSequence(450L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertFalse(built.contains("/orders/17/pay"), + "a factory ran after an earlier one had already redirected, so it built a " + + "screen and touched whatever is behind it for nothing: " + built); + } finally { + Navigation.setDispatcher(null); + } + } + /** * A redirect started inside a route FACTORY wins over the stack being rebuilt. * From 9b124a514d4a69f158dee5149188a8bd45e90023 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:56:31 +0300 Subject: [PATCH 122/140] Give supersession a direction, and clear only the stack the restore installed Replacing a same-origin offer is supersession, and supersession has a direction. The comment there has always said the newer sequence is the one worth showing and nothing checked: arrivals do not land in the order they were sent, so a delayed sequence 10 landing after 11 replaced it and moved the user backward. admit() has this check; the pre-enable path does not go through admit(), so the states a synced-store listener's seam collects before enable() arrive here unordered -- and both copies have already been claimed from the port, so the newer one is simply gone. And the lifecycle branch emptied the stack unconditionally. A callback that ends the session and then goes somewhere -- clear() and then navigate("/login"), the ordinary shape of a logout discovered mid-restore -- has already replaced it, so emptying removed the login entry too: the display guard kept the login FORM, and getCurrent() showed it while Navigation.getCurrent() was null and back() had nothing. disable() during a restore did worse, destroying the pre-restore history for something that is not a logout at all. Same rule as the two rollbacks in Navigation: undo what this restore installed, leave what application code chose. The tests in this class also clean the navigation stack now. Nothing resets Navigation between them, so a test that leaves entries behind breaks the NEXT test's fixture rather than its own assertions -- which is exactly what happened when the unconditional clear stopped covering for it, and the full-suite run caught it where the single-test runs could not. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 43 ++++-- .../continuity/LocalContinuityTest.java | 133 ++++++++++++++++++ 2 files changed, 167 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 6d7c0821ea7..360345e1c45 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1263,13 +1263,26 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // account's screens were back in the history even with nothing written to storage. // Suppressed while doing it, or the emptying schedules a checkpoint of its own and // recreates exactly what the logout removed. - try { - clearingStack = true; - Navigation.clearStack(); - } catch (Throwable t) { - Log.e(t); - } finally { - clearingStack = false; + // + // ONLY while the stack is still the restoration's own. A callback that ends the + // session and then goes somewhere -- clear() and then navigate("/login"), which is + // the ordinary shape of a logout discovered mid-restore -- has already replaced it, + // and emptying it then removed the login entry too: the display guard below kept the + // login FORM, so getCurrent() showed it while Navigation.getCurrent() was null and + // back() had nothing. disable() during a restore did worse, since it is not a logout + // and the pre-restore history was destroyed for it. + // + // Same rule as the two rollbacks in Navigation: undo what this restore installed, and + // leave what application code chose afterwards. + if (currentRoutes().equals(routes)) { + try { + clearingStack = true; + Navigation.clearStack(); + } catch (Throwable t) { + Log.e(t); + } finally { + clearingStack = false; + } } // And the SCREEN, which the stack does not speak for. restoreStack() has already // shown the rebuilt form by the time control gets here -- the cancellation came from @@ -2541,14 +2554,26 @@ private static void dispatch(AppState state) { /// this state, so a newer mark for that origin is left alone. private static void placeOnOffer(AppState state) { AppState replaced = parked; - parked = state; if (replaced == null || replaced == state) { //NOPMD CompareObjectsWithEquals + parked = state; return; } String origin = replaced.getDeviceId(); - if (origin == null || origin.length() == 0 || origin.equals(state.getDeviceId())) { + if (origin != null && origin.length() > 0 && origin.equals(state.getDeviceId())) { + // SAME device, so this is supersession -- and supersession has a direction. The + // comment below has always said the newer sequence is the one worth showing, and + // nothing checked: arrivals do not necessarily land in the order they were sent, and + // a delayed sequence 10 landing after 11 moved the user BACKWARD. admit() has this + // check, but the pre-enable path does not go through admit(), so the states a + // synced-store listener's seam collects before enable() arrive here unordered and + // both copies have already been claimed from the port. + if (state.getSequence() < replaced.getSequence()) { + return; + } + parked = state; return; } + parked = state; Long mark = lastSeen.get(origin); if (mark != null && mark.longValue() == replaced.getSequence()) { lastSeen.remove(origin); 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 index bb763d9b355..28b7266bfbe 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -4066,7 +4066,10 @@ public Form dispatch(String url) { + "the state and there is nothing left to retry from -- while the " + "user sits on a screen they did not ask for"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4388,7 +4391,10 @@ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + "on a history that does not include what the user is looking at; " + "top is " + Navigation.getCurrent().getPath()); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4441,7 +4447,10 @@ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + "previous account's screen; showing " + Display.getInstance().getCurrent().getTitle()); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4488,7 +4497,10 @@ public Form dispatch(String url) { + "signed-out account's UI; showing " + Display.getInstance().getCurrent().getTitle()); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4531,7 +4543,10 @@ public Form dispatch(String url) { + "was invoked for that account, and emptying the stack afterwards " + "undoes none of what they did"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4578,6 +4593,97 @@ public void aStateThatExpiredWhileTheUserDecidedIsRefused() { } } + /** + * An out-of-order arrival from the same device does not move the user backward. + * + *

Replacing a same-origin offer is supersession, and supersession has a direction. The + * comment said the newer sequence is the one worth showing and nothing checked: arrivals do + * not necessarily land in the order they were sent, so a delayed sequence 10 landing after 11 + * replaced it. admit() has this check, but the pre-enable path does not go through admit() -- + * the states a synced-store listener's seam collects before enable() arrive here unordered, + * and both copies have already been claimed from the port.

+ */ + @EdtTest + public void anOutOfOrderArrivalDoesNotMoveTheUserBackward() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("the newer work")) + .setDeviceId("phone").setSequence(11L) + .setTimestamp(System.currentTimeMillis())); + // The delayed one lands second. + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("the older work")) + .setDeviceId("phone").setSequence(10L) + .setTimestamp(System.currentTimeMillis())); + + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "nothing is on offer"); + assertEquals(11L, onOffer.getSequence(), + "a delayed older state from the same device replaced the newer one, so the user " + + "is moved backward and the newer continuation is gone"); + + // And a genuinely newer one still supersedes, or this guard would freeze the slot. + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("newer still")) + .setDeviceId("phone").setSequence(12L) + .setTimestamp(System.currentTimeMillis())); + assertEquals(12L, Continuity.getRestorableState().getSequence(), + "a newer state no longer supersedes, so the slot is stuck on the first arrival"); + } + + /** + * A screen chosen after the session ended keeps its stack entry. + * + *

A callback that ends the session and then goes somewhere -- clear() and then + * navigate("/login"), the ordinary shape of a logout discovered mid-restore -- has already + * replaced the stack. Emptying it then removed the login entry too, and the display guard + * kept the login FORM, so getCurrent() showed it while Navigation.getCurrent() was null and + * back() had nothing to go back to.

+ */ + @EdtTest + public void aScreenChosenAfterTheSessionEndedKeepsItsStackEntry() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + if ("/orders/17".equals(url)) { + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + if (Continuity.isEnabled()) { + Continuity.clear(); + Navigation.navigate("/login"); + } + } + }); + } + return f; + } + }); + try { + Continuity.restore(new AppState() + .setRoutes(java.util.Arrays.asList("/orders/17")) + .setDeviceId("some-other-device").setSequence(460L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertNotNull(Navigation.getCurrent(), + "the stack was emptied along with the restoration's own entries, so the login " + + "screen is showing with no history behind it and back() has nothing"); + assertEquals("/login", Navigation.getCurrent().getPath(), + "the stack does not name the screen the logout chose; top is " + + Navigation.getCurrent().getPath()); + } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** * A route-only arrival whose factory redirects is settled, not parked for ever. * @@ -4615,7 +4721,10 @@ public Form dispatch(String url) { "the arrival was never settled, so it stays parked, holds relay publication, " + "and is offered again after every launch to redirect again"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4654,7 +4763,10 @@ public Form dispatch(String url) { "a factory ran after an earlier one had already redirected, so it built a " + "screen and touched whatever is behind it for nothing: " + built); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4699,7 +4811,10 @@ public Form dispatch(String url) { "the rebuild replaced the screen the factory redirected to; top is " + Navigation.getCurrent().getPath()); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -4935,7 +5050,10 @@ public void aRouteTooLongToStoreIsDroppedFromWhatIsCommitted() { + "usableRoutes() had already dropped: it stays parked, is re-applied " + "on every retry, and holds every relay publication behind it"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -5035,7 +5153,10 @@ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + "rather than the ones the application redirected to, so a process " + "death restores the screen it sent the user away from"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -5462,7 +5583,10 @@ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + "the signed-out account's UI; showing " + Display.getInstance().getCurrent().getTitle()); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -5554,7 +5678,10 @@ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + "payload applied, so the relay's only other copy is released while " + "the user is not on the restored screen"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -5601,7 +5728,10 @@ public void anOversizedTitleIsRefusedAtTheCallAndALongRouteDoesNotStopCheckpoint + "make: capture() threw, dirty stayed set, and nothing was written " + "or published again"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } @@ -5864,7 +5994,10 @@ public void restoreState(Map payload) { + "form constructors and show callbacks all ran, and undoing the " + "stack afterwards cannot unrun them"); } finally { + // The stack too: nothing resets Navigation between tests, and a test that leaves + // entries behind breaks the NEXT test's fixture rather than its own assertions. Navigation.setDispatcher(null); + Navigation.clearStack(); } } From b5aa480e6791b8d08d62402abc5954262833f4c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:49:16 +0300 Subject: [PATCH 123/140] Shelve a displaced arrival instead of betting on a redelivery The offer slot holds one arrival, which is the right shape for getRestorableState() -- the application is asked about one thing at a time. It is the wrong shape for HOLDING, and those two jobs shared one field. Two devices can each offer work while automatic restoration is off, or while a listener defers both, and the second arrival simply overwrote the first. The previous fix forgot the displaced state's admission mark so that a redelivery could bring it back. That bet on a delivery which is not coming. The off-EDT callback claims what it queues -- it has to, the decision is made later on the event thread and the port is owed an answer now -- so a conforming bridge is entitled to drop its copy the moment it hands over. Nothing would ever deliver that state again. So displacement shelves rather than drops, one entry per origin, and getRestorableState() promotes the newest shelved arrival once the slot empties. The public shape is unchanged: still one state at a time, still the newest first. Every way an arrival ends had to reach the shelf as well as the slot, which is where the increments in this area have kept going wrong -- a fix applied to one exit of several. All of them, enumerated: a restore that commits, an acknowledge(), a tombstone from that origin, expiry, clear(), disable() on both its paths, and reset(). Relay publication is held for a shelved arrival too, and more obviously than for a parked one: the port has already been told the framework took it, so this process holds the only copy there is. Bounded at eight, oldest evicted. The shelf holds whole states, payloads included, and the device ids that key it come off the wire -- an unbounded one lets whatever is on the other end of the relay decide how much memory this process uses. The shelf takes only states that carry an origin. Keying an unidentified one under null looked harmless and was not: with two of them in a row, the same call that shelved the first looked a null origin straight back up, pulled it out again, and -- its sequence being the higher of the two -- handed it the slot and dropped the arrival that had just displaced it. The loss this mechanism exists to prevent, produced by the mechanism. Nothing the shelf does works without an origin, and a continuation always carries one; what arrives here without one is a state the application built and handed to restore(), which it still holds a reference to. Seven tests, each probed by mutating the guard it covers and confirming it fails: the two rewritten cross-origin tests now assert the state comes back with NOTHING redelivering it, and the new ones cover the tombstone, the publication hold, expiry with the application never asking (the case the slot's own expiry cannot reach, since asking is what discards a parked state), clear(), the bound, and the unidentified-state swap above. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 272 ++++++++++++-- .../continuity/LocalContinuityTest.java | 341 ++++++++++++++++-- 2 files changed, 549 insertions(+), 64 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 360345e1c45..52b0f00e5a2 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -148,6 +148,15 @@ public final class Continuity { /// than anything durable. private static final int MAX_SEEN = 64; + /// How many displaced arrivals the shelf keeps. + /// + /// Far smaller than MAX_SEEN, which bounds a map of longs: this one holds whole states, + /// payloads included, and the device ids that key it come off the wire. An unbounded shelf + /// lets a peer decide how much memory this process uses, so it is bounded and the OLDEST goes + /// -- the newest work is the work the user is most likely to still want. One entry per device + /// means this is a count of the user's devices, and eight is past any real account. + private static final int MAX_SHELVED = 8; + /// 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 @@ -211,6 +220,24 @@ public final class Continuity { /// A state that arrived and could not be shown yet. private static AppState parked; + /// Arrivals displaced from that slot by a state from a DIFFERENT device, newest per origin. + /// + /// The slot answers getRestorableState(), and one slot is the right shape for that -- the + /// application is asked about one thing at a time. It is the wrong shape for HOLDING, and + /// those are two different jobs that used to share the field. Two devices can each offer work + /// while automatic restoration is off, and the second arrival simply overwrote the first. + /// + /// That was survivable for as long as the port still had its copy: the framework declined + /// off-EDT, the port kept it, and installing a callback re-offered it. It stopped being + /// survivable when the callback started CLAIMING what it queues -- which it has to, because + /// the decision is made later on the EDT and the answer is owed now -- since a conforming + /// bridge is then entitled to drop its copy. Nothing would ever deliver that state again. + /// + /// So displacement shelves rather than drops, and getRestorableState() promotes the newest + /// shelved arrival once the slot empties. Bounded by how many devices the user has, one entry + /// each, aged out by getMaxAge() like everything else that waits. + private static final Map shelved = new LinkedHashMap(); + /// Which relay session the in-flight worker belongs to, bumped by `clear()`, `setRelay()` and /// `reset()`. /// @@ -399,6 +426,7 @@ public static void disable() { // said it wanted none. The full path clears it too, a few lines down; hoisting it here // covers both and says once that a disable() drops what is held. parked = null; + shelved.clear(); if (!enabled) { // A callback is installed even though nothing is being turned off, and it is the only // way to reach an arrival that is already waiting. iOS parks a cold-launch Handoff @@ -432,6 +460,7 @@ public static void disable() { enabled = false; dirty = false; parked = null; + shelved.clear(); // The relay session ends too. A checkpoint whose publish was deferred behind a fetch is // still sitting in the slot, and pollFinished() starts a publisher when that fetch lands // -- so without this a state queued before disable() went out on the wire after it had @@ -921,26 +950,16 @@ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFaile /// /// the state, or null when there is nothing to restore or it is older than `getMaxAge()` public static AppState getRestorableState() { - AppState waiting = parked; + // Aged like a stored one, and the shelf with it. An arrival that could not be shown yet + // -- during a cold launch, say -- has time passing while it waits, so exempting it would + // have let exactly the expiry the application configured slip through on the one path + // where the delay is longest. nextOffer() applies that and promotes a shelved arrival + // once the slot empties; when everything on offer has expired we fall through to the + // stored checkpoint below, which is ordinary with automatic restore off and the user + // still navigating. + AppState waiting = nextOffer(); if (waiting != null) { - // 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. - parked = null; - // And the checkpoint that was waiting behind it goes out. The hold is there to - // protect the relay's only copy of a live arrival; this one has expired and will - // not be restored by anything, so holding a publication for it forever is just a - // checkpoint that never reaches the user's other devices. - startPublisher(); - } else { - return waiting; - } + return waiting; } AppState stored = readStored(); if (stored == null || isTooOld(stored)) { @@ -1017,6 +1036,7 @@ public static boolean restore() { // `failed` is the distinction `shown` cannot make. False means both "there was no form to // show, and that is success" and "this did not work", which need opposite handling here -- // the same conflation that put two flags in capture() and in checkpoint(). + settleShelved(state); if (supersedesParked(state)) { parked = null; // The slot is what holds a publication back; the decision has been made, so anything @@ -1111,6 +1131,10 @@ private static boolean restore(final AppState state, boolean[] outFailed) { if (parked == state) { //NOPMD CompareObjectsWithEquals parked = null; } + // The shelf too: a state handed back here can be one the application kept from an + // earlier dispatch, whose copy was displaced into the shelf while it decided. Refusing + // the one in its hand and leaving the twin on offer would hand it straight back. + settleShelved(state); startPublisher(); return false; } @@ -1585,6 +1609,7 @@ public static void clear() { ? Display.getInstance().getCurrent() : null; lifecycle++; parked = null; + shelved.clear(); dirty = false; // The PORT's held arrival too, not only this class's parked slot. A Handoff that // cold-launches a logged-out app reaches IOSContinuityCallbacks before anything has @@ -2016,7 +2041,16 @@ private static void startPublisher() { // "null-check a static, then assign a static inside the branch" and reports it as a lazy // initializer, which this is not -- and the project's gate has no per-finding allow list, // so the shape is what has to change. + purgeShelf(); AppState awaitingDecision = parked; + if (awaitingDecision == null) { + // The shelf holds publication back too. A displaced arrival's only copy is in this + // process just as much as the slot's is -- more so, since the port has already been + // told the framework took it -- so publishing over the relay's document while one + // waits loses it for good. Peeked, not taken: promotion belongs to + // getRestorableState(), which is where the application asks for the next one. + awaitingDecision = newestShelved(); + } if (awaitingDecision != null) { // A fetched state is waiting on the user and has NOT been acknowledged. The relay // holds one document per user, so publishing now replaces the only copy of it that @@ -2313,6 +2347,7 @@ private static void admit(final AppState state) { // it, and the publication hold would go on withholding this device's checkpoints // behind it. Same shape as acknowledge() and expiry -- another way an arrival ends, // and every one of them has to release the slot. + settleShelved(state); AppState waiting = parked; if (waiting != null && state.getDeviceId().equals(waiting.getDeviceId()) && waiting.getSequence() <= state.getSequence()) { @@ -2549,34 +2584,189 @@ private static void dispatch(AppState state) { /// is in the in-memory map from admission, so a redelivery in the same run is refused as /// already seen. Recorded as handled and then dropped. /// - /// So the mark goes with it. Only the in-memory one: durableSeen is written when a state - /// COMPLETES, and this one never did, so nothing durable claims it. Only when it still names - /// this state, so a newer mark for that origin is left alone. + /// So the displaced state goes to the shelf instead. Forgetting its admission mark -- which + /// is what this did before -- only permitted a redelivery that is not coming, because the + /// off-EDT callback claims what it queues and the port is free to let go of its copy. See the + /// `shelved` field. private static void placeOnOffer(AppState state) { AppState replaced = parked; - if (replaced == null || replaced == state) { //NOPMD CompareObjectsWithEquals - parked = state; + if (replaced != null && replaced != state) { //NOPMD CompareObjectsWithEquals + if (isSameOrigin(replaced, state)) { + // SAME device, so this is supersession -- and supersession has a direction. The + // comment above has always said the newer sequence is the one worth showing, and + // nothing checked: arrivals do not necessarily land in the order they were sent, + // and a delayed sequence 10 landing after 11 moved the user BACKWARD. admit() has + // this check, but the pre-enable path does not go through admit(), so the states a + // synced-store listener's seam collects before enable() arrive here unordered and + // both copies have already been claimed from the port. + if (state.getSequence() < replaced.getSequence()) { + return; + } + } else { + shelve(replaced); + } + } + parked = state; + String origin = state.getDeviceId(); + if (origin == null || origin.length() == 0) { + // Nothing to reconcile, and asking would be wrong: the shelf keys an unidentified + // state under a null origin, so looking one up here would pull back the state this + // call had just displaced -- and, if its sequence happened to be the higher of the + // two, hand it the slot and drop the arrival that displaced it. return; } - String origin = replaced.getDeviceId(); - if (origin != null && origin.length() > 0 && origin.equals(state.getDeviceId())) { - // SAME device, so this is supersession -- and supersession has a direction. The - // comment below has always said the newer sequence is the one worth showing, and - // nothing checked: arrivals do not necessarily land in the order they were sent, and - // a delayed sequence 10 landing after 11 moved the user BACKWARD. admit() has this - // check, but the pre-enable path does not go through admit(), so the states a - // synced-store listener's seam collects before enable() arrive here unordered and - // both copies have already been claimed from the port. - if (state.getSequence() < replaced.getSequence()) { - return; + // The slot and the shelf never hold one origin twice. Whatever this origin had shelved is + // this arrival's predecessor -- or, if the two crossed on the wire, its successor, and + // then the newer one takes the slot back under the same direction rule as above. + AppState kept = shelved.remove(origin); + if (kept != null && kept != state //NOPMD CompareObjectsWithEquals + && kept.getSequence() > state.getSequence()) { + parked = kept; + } + } + + /// Keeps a displaced arrival, newest per origin. + /// + /// One entry per device rather than a queue: an origin that has moved on has superseded its + /// own earlier state, which is the rule supersedesParked() already applies to the slot. So the + /// shelf grows with how many devices the user has, not with how many states they send -- and + /// then MAX_SHELVED bounds even that, because the device ids come off the wire and nothing + /// here gets to trust them. + private static void shelve(AppState state) { + String origin = state.getDeviceId(); + if (origin == null || origin.length() == 0) { + // Not shelved at all. Everything the shelf does -- supersede, settle, promote -- is + // keyed by origin, so a state that does not say where it came from cannot take part + // in any of it. And it is never the arrival this exists for: a cross-device + // continuation always carries an id, and what reaches here without one is a state the + // application built and handed to restore(), which it still holds a reference to. + return; + } + AppState kept = shelved.get(origin); + if (kept != null && kept.getSequence() > state.getSequence()) { + return; + } + shelved.put(origin, state); + while (shelved.size() > MAX_SHELVED) { + AppState oldest = null; + Iterator i = shelved.values().iterator(); + while (i.hasNext()) { + AppState candidate = i.next(); + if (oldest == null || candidate.getTimestamp() < oldest.getTimestamp()) { + oldest = candidate; + } + } + if (oldest == null) { + // Unreachable while size() is over the cap, and the loop must not spin if it ever + // is not. + break; } - parked = state; + shelved.remove(oldest.getDeviceId()); + } + } + + /// Whether two arrivals came from the same device. + /// + /// An absent origin is not a device: two states that both fail to say where they came from + /// are not each other's supersession, so they displace rather than replace. + private static boolean isSameOrigin(AppState a, AppState b) { + String origin = a.getDeviceId(); + return origin != null && origin.length() > 0 && origin.equals(b.getDeviceId()); + } + + /// The arrival on offer: the slot, promoting the newest shelved one when it has emptied, and + /// expiring whatever has aged out on the way past. + /// + /// Expiry is applied HERE rather than when the state is shelved because getMaxAge() is + /// measured when the question is asked -- an arrival that was fresh when it was displaced can + /// be stale by the time the slot frees up, and that wait is exactly what the age is for. + private static AppState nextOffer() { + while (true) { + // Through a local, for the reason startPublisher() gives: PMD's NonThreadSafeSingleton + // matches the SHAPE of "null-check a static, then assign that static inside the + // branch" and reports it as a lazy initializer. This is a slot being refilled, not an + // instance being created, and the project's gate has no per-finding allow list. + AppState offer = parked; + if (offer == null) { + offer = takeShelved(); + if (offer == null) { + return null; + } + parked = offer; + } + if (!isTooOld(offer)) { + return offer; + } + // Cleared, and then we keep looking -- past the rest of the shelf and, in the caller, + // on to the stored checkpoint. Returning null at the first expired arrival reported + // "nothing to restore" while a perfectly valid state sat behind it. + parked = null; + // And the checkpoint that was waiting behind it goes out. The hold is there to + // protect the relay's only copy of a live arrival; this one has expired and will not + // be restored by anything, so holding a publication for it forever is just a + // checkpoint that never reaches the user's other devices. + startPublisher(); + } + } + + /// Removes and returns the newest shelved arrival, or null. + private static AppState takeShelved() { + AppState newest = newestShelved(); + if (newest != null) { + shelved.remove(newest.getDeviceId()); + } + return newest; + } + + /// The newest shelved arrival without removing it, or null. + /// + /// Newest by the ORIGIN's clock. Sequences are per-device counters, so comparing one device's + /// against another's says nothing at all -- the timestamp is the only ordering two devices + /// share, and it is the one last-writer-wins already uses. + private static AppState newestShelved() { + AppState newest = null; + Iterator i = shelved.values().iterator(); + while (i.hasNext()) { + AppState candidate = i.next(); + if (newest == null || candidate.getTimestamp() > newest.getTimestamp()) { + newest = candidate; + } + } + return newest; + } + + /// Drops shelved arrivals that have aged past getMaxAge(). + /// + /// Called from startPublisher() as well as nextOffer(), because the shelf holds publication + /// back the same way the slot does: without this, an application that never asks for a + /// restorable state would stop publishing for the rest of the process over an arrival that + /// expired hours ago. + private static void purgeShelf() { + if (shelved.isEmpty()) { return; } - parked = state; - Long mark = lastSeen.get(origin); - if (mark != null && mark.longValue() == replaced.getSequence()) { - lastSeen.remove(origin); + Iterator i = shelved.values().iterator(); + while (i.hasNext()) { + if (isTooOld(i.next())) { + i.remove(); + } + } + } + + /// Drops whatever `state` settles from the shelf: the same origin, at or behind it. + /// + /// The shelf is subject to every way an arrival ends, exactly as the slot is -- a restore, an + /// acknowledgement, or a tombstone from that origin finishes its shelved state too. Missing + /// this would leave work on offer that the origin has already moved past, and hold this + /// device's checkpoints behind it. + private static void settleShelved(AppState state) { + String origin = state.getDeviceId(); + if (origin == null || origin.length() == 0) { + return; + } + AppState kept = shelved.get(origin); + if (kept != null && kept.getSequence() <= state.getSequence()) { + shelved.remove(origin); } } @@ -2797,6 +2987,7 @@ private static void noteActedOn(AppState state) { recordDurable(from, seq); } } + settleShelved(state); if (supersedesParked(state)) { // The application has dealt with this arrival -- acknowledge() is the documented way // to decline one -- so the slot must not go on offering it through @@ -3366,6 +3557,7 @@ static void reset() { maxAge = 0; deviceId = null; parked = null; + shelved.clear(); dirty = false; waitingForWindow = false; applyingRestore = false; 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 index 28b7266bfbe..992d44d1ad0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -42,8 +42,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -4872,9 +4874,13 @@ public void restoreState(Map payload) { * *

Returning false keeps an arrival on offer -- the documented prompt-then-restore pattern. * Do it for device A and then for device B before A is resolved and B replaced A in the slot, - * with A's mark still recorded, so A could never be offered again in that run. The - * bookkeeping that makes a replacement recoverable was added at two of the five places a - * state is put on offer, and this is one of the three it missed.

+ * so A could never be offered again in that run.

+ * + *

The first fix for this forgot A's admission mark, so a REDELIVERY could bring it back. + * That bet on a delivery which is not coming: the off-EDT callback claims what it queues -- + * it has to, the decision is made later on the EDT and the port is owed an answer now -- so a + * conforming bridge is entitled to drop its copy the moment it hands over. A is kept on the + * shelf instead, and comes back with nothing redelivering it.

*/ @EdtTest public void aListenerHoldingTwoArrivalsDoesNotLoseTheFirst() { @@ -4904,30 +4910,39 @@ public boolean stateReceived(AppState state) { assertNull(Continuity.readSeenForTest().get("phone"), "a durable mark was left for a held state that was never completed"); - // The phone's work comes round again -- another Handoff, or the next relay read. It has - // to be offerable, because nothing ever dealt with it. - Continuity.deliver(fromPhone); - flushSerialCalls(); + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "nothing is on offer at all"); + assertEquals("tablet", onOffer.getDeviceId(), + "the newer arrival is not the one on offer"); + + // The user deals with the tablet's. NOTHING redelivers the phone's -- no second Handoff, + // no relay read -- and it still has to come back, because nothing ever dealt with it. + Continuity.acknowledge(onOffer); AppState back = Continuity.getRestorableState(); - assertNotNull(back, "the phone's held state came back to nothing"); + assertNotNull(back, + "the state the listener was holding for the phone was dropped when the tablet's " + + "replaced it in the slot, and no redelivery is coming for it: the " + + "callback claimed it off-EDT, so the port has let go of its copy"); assertEquals("phone", back.getDeviceId(), - "the state the listener was holding for the phone was refused as already seen " - + "after the tablet's replaced it, so it is lost for the rest of the run"); + "the phone's held state was not what came back once the tablet's was settled"); + assertEquals("from the phone", back.getPayload().get("note"), + "something with the phone's device id came back, but not its payload"); } /** - * An offer replaced by one from ANOTHER device can be delivered again. + * An offer replaced by one from ANOTHER device is kept, not dropped. * *

The slot holds one arrival, which is right, and replacing it is right when the two come * from the same device -- that is supersession. Two different devices are not that: with * automatic restoration off both can be dispatched before the application calls restore(), - * and the second overwrote the first. That would be survivable if the first could come back, - * and it could not: its (origin, sequence) went into the in-memory map at admission, so a - * redelivery in the same run was refused as already seen. Recorded as handled and then - * dropped.

+ * and the second overwrote the first.

+ * + *

The displaced arrival goes to the shelf and is promoted when the slot empties. This test + * pins that it comes back WITHOUT a redelivery, which is the part the earlier mark-forgetting + * fix could not provide -- see the sibling test above for why no redelivery is coming.

*/ @EdtTest - public void anOfferReplacedByAnotherDeviceCanBeDeliveredAgain() { + public void anOfferReplacedByAnotherDeviceIsKeptRatherThanDropped() { RecordingProvider provider = new RecordingProvider(); Continuity.setStateProvider(provider); Continuity.setAutoRestore(false); @@ -4951,17 +4966,30 @@ public void anOfferReplacedByAnotherDeviceCanBeDeliveredAgain() { assertEquals("tablet", onOffer.getDeviceId(), "the newer arrival is not the one on offer"); - // The phone's state was dropped from the slot. It must not ALSO be remembered as handled, - // or the relay and the port can never offer it again for the rest of the run. + // The phone's state was displaced from the slot. It must not ALSO be remembered as + // handled, because nothing ever handled it. assertNull(Continuity.readSeenForTest().get("phone"), "a durable mark was left for a state that was never completed"); - Continuity.deliver(first); - flushSerialCalls(); + + Continuity.acknowledge(onOffer); AppState back = Continuity.getRestorableState(); - assertNotNull(back, "the phone's state came back to nothing"); - assertEquals("phone", back.getDeviceId(), - "the phone's state was refused as already seen, though it was dropped without " - + "ever being handled -- so it is lost for the rest of the process"); + assertNotNull(back, + "the phone's state was dropped when the tablet's took the slot, so it is lost for " + + "the rest of the process"); + assertEquals("phone", back.getDeviceId(), "the phone's state was not what came back"); + assertEquals("from the phone", back.getPayload().get("note"), + "something with the phone's device id came back, but not its payload"); + + // And settling THAT one leaves nothing from elsewhere behind: the shelf is a hold for + // work that arrived, not a queue that grows. What may still be offered is this device's + // own stored checkpoint, which getRestorableState() falls through to and which is not + // what this is about. + Continuity.acknowledge(back); + AppState after = Continuity.getRestorableState(); + assertTrue(after == null + || (!"phone".equals(after.getDeviceId()) + && !"tablet".equals(after.getDeviceId())), + "an arrival from another device is still on offer after both were settled"); } private static Map payloadWith(String note) { @@ -7211,6 +7239,271 @@ public void run() { "the checkpoint stayed held behind work the origin had already cleared"); } + /** + * A tombstone also clears work that origin left on the SHELF. + * + *

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

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

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

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

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

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

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

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

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

+ * + *

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

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

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

+ */ + @EdtTest + public void clearEmptiesTheShelfAsWellAsTheSlot() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Continuity.deliver(foreign("phone", 111L)); + flushSerialCalls(); + Continuity.deliver(foreign("tablet", 1L)); + flushSerialCalls(); + + Continuity.clear(); + + AppState left = Continuity.getRestorableState(); + assertNull(left, + "an arrival from before the logout survived clear() on the shelf, so the login " + + "that follows restores the previous account's work"); + } + /** * A logout between queueing a publish and the worker reaching the network must stop the * request. RestStateRelay resolves getToken() INSIDE publish(), so a quick logout and login From 3de7bc27047dc603c271834ba0a8fa7d69d83398 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:49:25 +0300 Subject: [PATCH 124/140] Give the JavaScript VideoIODecodedFrames baseline the tolerance every other port has Unrelated to continuity, and carried here because it is what has been keeping this branch's javascript-screenshots leg red. It also fails on branches with no continuity code at all, so it is not this work's doing. The test encodes six frames and decodes them back, comparing whatever the platform codec returns -- lossy, and not bit-reproducible. Its own javadoc says each baseline ships a generous .tolerance file, and every comparing port does, at identical values. scripts/javascript/screenshots/ was the one that did not, so readTolerance fell back to the harness defaults in ProcessScreenshots -- maxChannelDelta=4, maxMismatchPercent=0.30 -- and ordinary decode noise failed the build. Measured on the failure rather than assumed: the worst single-channel delta anywhere was 20/255, 2.611% of pixels exceeded delta 4, and 0.000% exceeded delta 72. The regressions this test exists to catch -- frames blank, reordered, wrong colour -- move whole blocks across most of the channel range, so this does not stop catching them. The baseline PNG is untouched, so this is not a reseed. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshots/VideoIODecodedFrames.tolerance | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 scripts/javascript/screenshots/VideoIODecodedFrames.tolerance diff --git a/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance b/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance new file mode 100644 index 00000000000..15da581f5ed --- /dev/null +++ b/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance @@ -0,0 +1,13 @@ +# The test encodes six frames and decodes them back, so it compares whatever +# the platform codec returns -- lossy, and not bit-reproducible. Every other +# comparing port ships this same sidecar at these values; JavaScript was the +# one baseline without it, so readTolerance fell back to the harness defaults +# (delta 4 / 0.30%) and ordinary decode noise failed the build. +# +# Measured on the failure that prompted this: worst single-channel delta +# anywhere was 20/255, 2.611% of pixels exceeded delta 4, and 0.000% exceeded +# delta 72. The regressions this test exists to catch -- frames blank, +# reordered, wrong colour -- move whole blocks across most of the channel +# range, so they stay caught. +maxChannelDelta=72 +maxMismatchPercent=18.0 From 9902e3c6eccd5b6b771860d3870d847fb4ab4e06 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:05:18 +0300 Subject: [PATCH 125/140] Read an injected plist value the way Foundation does, and refuse a duplicated key The open-source half of the same three findings raised on the builder mirror; the two builders have to answer a fragment identically or the same project gets a different plist locally than it does in the cloud. topLevelPlistString sliced the raw XML between the tags and handed back the undecoded text, so a project that spells its injected type with a character reference -- "com.example.app.continuity", which Foundation resolves to "com.example.app.continuity" -- had it compared as a different string. withContinuityActivityType called that a conflicting declaration and failed a build that was correct. The KEY half of the same method already resolved through plistStringContentExact, which is what makes the fragment findable at all, so the two halves were answering one question two different ways. Two live root NSUserActivityTypes declarations are now refused rather than guessed between, which is the trap UIApplicationSceneManifest is already refused for and resolves the same way: a property list takes the LAST of a duplicated key while every lookup here answers with the first, so merging into the first leaves the second in force on the device -- a build that succeeds with the activity types in an array iOS never reads, and Handoff silently not advertised. plistMemberDuplicated walks live elements only, so a declaration the project kept COMMENTED OUT above its real one is still not a second declaration, which the live-element handling exists for and a test now pins. The third finding -- that a stale injected CN1ContinuityActivityType is stood aside for while the generated type is advertised -- does not hold. A live value that differs is refused two branches above the return it names, and a non-string one the branch above that, so only an AGREEING declaration reaches there, and both keys are written from the same resolved string a few lines apart in the caller. Recorded as a comment at that return, since the reachability is what makes it safe and nothing else said so. Four tests, each probed by mutating the guard it covers: reverting the resolver reproduces the refusal verbatim, and making the duplicate check comment-blind refuses the commented-out case it must accept. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 55 +++++++++++- .../IPhoneBuilderContinuityPlistTest.java | 87 +++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) 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 895cc991106..055663de507 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 @@ -11741,6 +11741,12 @@ static String withContinuityActivityType(String inject, String continuityType) + "'. The application would refuse its own continuations. Remove the " + "injected key."); } + // Only an AGREEING declaration reaches here. A value that differs was refused by the + // check above and a non-string one by the check above that, so this cannot leave the + // fragment advertising one type through NSUserActivityTypes while the delegate reads + // another -- both keys are written from the same resolved string a few lines apart in the + // caller, and a declaration that disagrees with it fails the build rather than being + // stood aside for. if (declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) { return inject; } @@ -12055,6 +12061,39 @@ private static void requireArrayForContinuity(String continuityType, String what + "out so the build writes the whole array itself."); } + /// Refuses a fragment that declares NSUserActivityTypes twice at the root. + /// + /// The trap UIApplicationSceneManifest is already refused for, and it resolves the same way: a + /// property list takes the LAST of a duplicated key, while every lookup here answers with the + /// first. Merging into the first would leave the second in force on the device -- a build that + /// succeeds with the activity types sitting in an array iOS never reads, so Handoff and + /// Spotlight are silently not advertised and nothing says so until they do not work. + /// + /// There is no safe pick between them. Fragments composed by more than one injector are how + /// this arises, and merging into either one is a guess about which the parser will keep, so it + /// is reported rather than guessed at. + /// + /// Comment-aware, because plistMemberRange walks live elements only: a declaration the project + /// kept COMMENTED OUT above its real one is not a second declaration, and refusing that would + /// break the very projects the live-element handling was added for. + /// + /// #### Parameters + /// + /// - `inject`: the plist fragment the application supplied + /// + /// #### Throws + /// + /// - `BuildException`: when the key is declared more than once at the root + static void requireSingleUserActivityTypes(String inject) throws BuildException { + if (!plistMemberDuplicated(inject, 0, inject.length(), "NSUserActivityTypes")) { + return; + } + throw new BuildException("ios.plistInject declares NSUserActivityTypes twice. A property " + + "list takes the last of a duplicated key, so this build cannot tell which array " + + "the device will read -- and the activity types would go into the other one. " + + "Compose them into one array."); + } + /// The same merge, adding the continuity activity type alongside the intent ids. /// /// #### Parameters @@ -13042,7 +13081,20 @@ static String topLevelPlistString(String plist, String key) { if (!value.startsWith("") || !value.endsWith("")) { return null; } - return value.substring("".length(), value.length() - "".length()).trim(); + // The element's CONTENT through the shared resolver, not a slice of its serialization. + // A plist author may spell a value with character references -- "com.example.app" -- + // or wrap it in CDATA, or put a comment inside the element, and Foundation reads all + // three as the same string. Handing back the raw text made every caller compare the + // serialization instead: withContinuityActivityType saw "com.example.app.continuity" + // where the build publishes "com.example.app.continuity", called that a conflicting + // declaration, and failed a build that was correct. + // + // The key side of this method already resolves the same way -- plistMemberRange compares + // key names through this helper -- so the two halves were answering one question two + // different ways. + String content = WatchNativeBuilder.plistStringContentExact( + value.substring("".length(), value.length() - "".length())); + return content == null ? null : content.trim(); } /// Whether the fragment declares the key as a member of ITS OWN level. @@ -14669,6 +14721,7 @@ public boolean accept(File file, String string) { // own: a valid CDATA value containing the text "" looked like an // unterminated comment, so everything after it was truncated and a live root key // beyond it went missing -- and this branch then appended a SECOND one. + requireSingleUserActivityTypes(inject); if (firstLiveRootIndex(inject, "NSUserActivityTypes") < 0) { inject += userActivityTypesKey(intentsManifest, continuityActivityType); } else { 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 index 820648730cd..56ce9f6a89b 100644 --- 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 @@ -169,6 +169,93 @@ void aNonStringDeclarationIsRefused() { } } + /** + * A declaration spelled with character references is the SAME declaration. + * + *

Foundation resolves {@code com.example.app.continuity} to + * {@code com.example.app.continuity}, so a project that spells its injected type that way has + * declared exactly what this build publishes. topLevelPlistString sliced the raw XML between + * the tags and handed back the undecoded text, so the equality check called it a CONFLICTING + * declaration and failed a build that was correct.

+ * + *

The key half of the same method already resolved through the shared helper -- that is + * what makes the fragment findable at all -- so the two halves were answering one question + * two different ways.

+ */ + @Test + void aDeclarationSpelledWithCharacterReferencesAgrees() throws BuildException { + String inject = "CN1ContinuityActivityType" + + "com.example.app.continuity"; + + String out = IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE); + + assertEquals(inject, out, + "an agreeing declaration spelled with a character reference was treated as a " + + "conflict, so a correct build was refused"); + } + + /** + * The same resolution must not blunt the conflict check itself. + * + *

Decoding is only correct if a genuinely different type still fails: a declaration naming + * another app's type has the delegate rejecting this application's own continuations while + * iOS goes on offering them.

+ */ + @Test + void aDifferentTypeSpelledWithCharacterReferencesStillConflicts() { + String inject = "CN1ContinuityActivityType" + + "com.other.app.continuity"; + + try { + IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE); + fail("a declaration naming a different type must still be refused"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("com.other.app.continuity"), + "the message should name the DECODED type the project declared: " + + expected.getMessage()); + } + } + + /** + * Two live root NSUserActivityTypes declarations are refused rather than guessed between. + * + *

A property list takes the LAST of a duplicated key while every lookup here answers with + * the first, so merging into the first leaves the second in force on the device: a build that + * succeeds with the activity types sitting in an array iOS never reads, and Handoff silently + * not advertised. UIApplicationSceneManifest is already refused for exactly this.

+ */ + @Test + void twoLiveActivityTypesDeclarationsAreRefused() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacy" + + "NSUserActivityTypes" + + "com.example.app.replacement"; + + try { + IPhoneBuilder.requireSingleUserActivityTypes(inject); + fail("a duplicated NSUserActivityTypes must not be silently merged into one of them"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("twice"), expected.getMessage()); + } + } + + /** + * A declaration the project COMMENTED OUT is not a second declaration. + * + *

The live-element handling exists for exactly this shape -- a project that kept its old + * declaration above the real one -- so a duplicate check that counted the comment would refuse + * the projects that handling was added for.

+ */ + @Test + void aCommentedOutActivityTypesDeclarationIsNotADuplicate() throws BuildException { + String inject = "" + + "NSUserActivityTypes" + + "com.example.app.replacement"; + + IPhoneBuilder.requireSingleUserActivityTypes(inject); + } + /** * A NSUserActivityTypes whose value is not an array is refused once a continuity type depends * on it. From efb5862006318357ef91d50612265d695a009e64 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:16:55 +0300 Subject: [PATCH 126/140] Bound the cold-launch window at both ends, not just the wait The wait is documented as bounded at fifteen seconds and was bounded in only one of the two places it has to be. The waiter asked the event thread whether a window had appeared through an UNTIMED callSeriallyAndWait. A cold launch is exactly when that thread may be busy for a long time -- a slow device building its first forms -- and the question then blocked with it, so the loop could not recheck its own deadline. The timed overload with the REMAINING budget fixes that half: on timeout it just returns, leaving "not yet", which is the same answer a launch with no form gives and which the loop already handles. That is only half, and taking the suggestion alone would have left the other. The hand-back is a callSerially, so windowWaitFinished() runs on the event thread too -- whenever it recovers. A loop that ended exactly on time still had its decision made minutes later, and the decision is the dispatch: the continuation applied over whatever the user had started doing in the meantime, which is the interruption the bound exists to rule out. So the deadline is checked there as well. Past the deadline the arrival stays PARKED rather than being dropped. Nothing has dealt with it, so getRestorableState() goes on offering it and the application takes it when it chooses -- the same answer that method already gives when no form ever appeared. The wait itself still cannot be staged in a unit harness: it needs a launch with no form and an event thread that stops answering, and this one has neither. The decision it leads to is a single call, so that is what the test pins, through a seam that enters the drain with the window already closed. Probed by removing the deadline check: the arrival is dispatched into the running app, which is the reported failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 52 ++++++++++++++++--- .../continuity/LocalContinuityTest.java | 45 ++++++++++++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 52b0f00e5a2..27a464de9e5 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -2815,7 +2815,7 @@ private static void park(AppState state) { Display.getInstance().startThread(new Runnable() { @Override public void run() { - long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS; + final long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS; try { while (System.currentTimeMillis() < deadline) { try { @@ -2824,7 +2824,13 @@ public void run() { Thread.currentThread().interrupt(); break; } - if (haveWindow()) { + // The REMAINING budget, so the question cannot outlast the window it is + // being asked inside. An untimed wait here blocks until the event thread + // reaches the runnable, and a cold launch is exactly when it may not for a + // long time -- a slow device building its first forms. The loop then could + // not recheck its own deadline, so a "bounded 15 seconds" wait ran for as + // long as the event thread was busy. + if (haveWindow(deadline - System.currentTimeMillis())) { break; } } @@ -2836,7 +2842,7 @@ public void run() { Display.getInstance().callSerially(new Runnable() { @Override public void run() { - windowWaitFinished(); + windowWaitFinished(deadline); } }); } @@ -2857,15 +2863,23 @@ public void run() { /// Marshalled rather than guarded. This framework is single threaded on the event thread and /// the UI belongs to it; the fix for touching it from elsewhere is to stop doing that, not to /// put a lock around state that has no business being shared. - private static boolean haveWindow() { + private static boolean haveWindow(long budgetMillis) { + if (budgetMillis <= 0) { + return false; + } final boolean[] present = new boolean[1]; try { + // The TIMED overload. On timeout it simply returns, leaving `present` false -- "not + // yet", which is the same answer a launch with no form gives and which the loop + // already handles by going round again or ending on the deadline. The runnable may + // run later and write to the array after we have stopped reading it; nothing else + // ever looks at it, so that write goes nowhere. Display.getInstance().callSeriallyAndWait(new Runnable() { @Override public void run() { present[0] = Display.getInstance().getCurrent() != null; } - }); + }, (int) Math.min(budgetMillis, (long) Integer.MAX_VALUE)); } catch (Throwable t) { // Answering "not yet" keeps the wait going, and the deadline still ends it. The // caller's finally reports back either way. @@ -2875,8 +2889,22 @@ public void run() { } /// The cold-launch wait is over. On the EDT. - private static void windowWaitFinished() { + /// + /// `deadline` is when the window closed, and it is checked HERE as well as in the loop because + /// this half runs on the event thread too: the waiter hands back through callSerially, so an + /// event thread that was busy past the deadline runs this whenever it recovers. Bounding only + /// the wait would have left that -- the arrival dispatched minutes later, replacing whatever + /// the user had started doing in the meantime, which is the interruption the bounded window + /// exists to rule out. + /// + /// Past the deadline the state stays PARKED rather than being dropped. Nothing has dealt with + /// it, so getRestorableState() goes on offering it and the application can restore it when it + /// chooses -- the same answer this method already gives when no form ever appeared. + private static void windowWaitFinished(long deadline) { waitingForWindow = false; + if (deadline > 0 && System.currentTimeMillis() >= deadline) { + return; + } if (Display.getInstance().getCurrent() == null) { // Still no form after the whole wait. The state stays parked, so an application that // gets going later can still ask for it through getRestorableState(). @@ -3151,7 +3179,17 @@ static void parkForTest(AppState state) { /// and this one always has one -- but the drain is the half that matters: it is where a state /// that was fresh when it arrived and expired while waiting reaches dispatch(). static void drainParkedForTest() { - windowWaitFinished(); + // No deadline: the harness is entering the drain directly, not finishing a timed wait. + windowWaitFinished(0L); + } + + /// Test seam: the same drain, entered after the cold-launch window has already closed. + /// + /// The event thread being busy past the deadline is the case that cannot be staged here -- + /// this harness always has a form and a responsive thread -- but the decision it leads to is + /// exactly this call, so that is what is pinned. + static void drainParkedPastTheWindowForTest() { + windowWaitFinished(System.currentTimeMillis() - 1L); } /// Test seam: the marks as they would be reloaded on the next launch. 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 index 992d44d1ad0..d6b9fa99bed 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7478,6 +7478,51 @@ public void aStateWithNoOriginNeverReachesTheShelf() { + "displaced it"); } + /** + * An arrival whose cold-launch window has closed is not dispatched into a running app. + * + *

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

+ * + *

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

+ */ + @EdtTest + public void anArrivalPastTheColdLaunchWindowIsNotDispatched() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + final int[] dispatched = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + dispatched[0]++; + return true; + } + }); + + Continuity.parkForTest(fromElsewhere("waited out the window", 140L)); + Continuity.drainParkedPastTheWindowForTest(); + + assertEquals(0, dispatched[0], + "an arrival was applied after its bounded window had closed, so it replaces " + + "whatever the user started doing while the event thread was busy"); + AppState still = Continuity.getRestorableState(); + assertNotNull(still, "the arrival was dropped rather than left on offer"); + assertEquals(140L, still.getSequence(), + "the arrival that outlived the window is no longer the one on offer"); + + // And the ordinary drain still dispatches, or this guard would have turned the cold-launch + // hand-over off altogether. + Continuity.drainParkedForTest(); + assertEquals(1, dispatched[0], + "the in-window drain stopped dispatching, so nothing is ever handed over"); + } + /** * clear() empties the shelf, not only the slot. * From 275ffdb7e42b27a346ff3959346c4cd96577ac26 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:13:19 +0300 Subject: [PATCH 127/140] Drain every held arrival at enable(), and write down the ordering clear() depends on Two findings on the offer shelf, one of them a regression the shelf itself introduced. enable() drained the slot and not the shelf. Two devices can each reach the seam before the application enables continuity -- a synced-store listener installs one without enabling anything, because a key/value store is not consent to restore a route stack -- and the second displaces the first onto the shelf. That first arrival was then in a state nothing resolved: never admitted, so its listeners and provider never ran, and reachable only if the application happened to call getRestorableState() by hand. I made that trade knowingly when the shelf went in, on the grounds that the state was at least still reachable. That was wrong even then, and the same change made it worse: the shelf holds relay publication now, so an arrival nothing was ever going to dispatch withheld every checkpoint this device made for the rest of the process. The drain takes both. Snapshotted and cleared BEFORE any of it is admitted, because admit() lands back in placeOnOffer() for anything a listener defers -- draining a live collection would either re-admit what was just put back or lose it. Oldest first by the ORIGIN's clock, which is the only ordering two devices share, so the newest is what remains on offer. Selection sort rather than Collections.sort: this is the core API surface, where what exists is what vm/JavaAPI and Ports/CLDC11 both define, and the count is the number of the user's devices. The loop rechecks `enabled` between admissions, since a listener is allowed to turn continuity off while it runs. The second finding is that clear()'s discard of what the port held relies on the bridge re-offering synchronously, which ContinuityBridge never required. That is true, and the fix is to require it rather than to widen the window. It has to be the call. A held continuation reaches the seam by exactly the same route a brand new one does and carries nothing that tells them apart, so the framework cannot bind the discard to the cleared session instead. And widening breaks the other half of the same promise: clear() is a logout, not "continuity off", and it deliberately leaves an enabled framework enabled, so a continuation that genuinely arrives after it -- for the account now signing in -- has to be delivered. Any window that outlasts the call starts eating those. ContinuityBridge is a PORT seam, implemented here and nowhere else, and both implementations already satisfy this: the iOS bridge hands its pending activity over inline, and a bridge holding nothing satisfies it trivially. Writing it down is what stops the next one being the exception. Both halves of the clear() promise are pinned by one test, because they are one promise. The first probe caught it asserting nothing: the "an arrival after the clear still arrives" half passed with the discard window left open for ever, because deliver() is a seam that enters at admit(), below where the flag is read. Routed through the bridge instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 95 ++++++++++++--- .../continuity/spi/ContinuityBridge.java | 21 ++++ .../continuity/LocalContinuityTest.java | 114 ++++++++++++++++++ 3 files changed, 215 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 27a464de9e5..8c9c57108a6 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -347,29 +347,81 @@ public static void enable() { // enabled continuity is parked here rather than left with the port -- see Callback.decide // -- so enabling has to look. On the next turn, because dispatch() runs application // listeners and enable() is not the place to do that. - if (parked != null) { + if (parked != null || !shelved.isEmpty()) { Display.getInstance().callSerially(new Runnable() { @Override public void run() { - AppState waiting = parked; - if (waiting != null && enabled) { - parked = null; - // Through admit(), not dispatch(). A port is allowed to retain the same - // continuation it declined, so both it and this class can be holding one - // copy each -- and installing the seam re-offers the port's while this - // drains ours. admission is where (origin, sequence) deduplication lives, - // so it is what makes the second copy a no-op; dispatching straight past - // it ran the listeners and the provider twice on one arrival. - // - // The comment that justified parking said the two copies dedup at - // admission. They only do if they both go through it. - admit(waiting); - } + drainPendingOffers(); } }); } } + /// Admits everything this class was holding before enable(), oldest first. On the EDT. + /// + /// The SHELF as well as the slot. Two devices can each reach the callback before the + /// application enables continuity -- a synced-store listener installs that seam without + /// enabling anything -- and the second displaces the first into the shelf. Admitting only the + /// slot left that first arrival in a state nothing resolves: never dispatched, so its + /// listeners and provider never saw it, and reachable only if the application happened to call + /// getRestorableState() by hand. + /// + /// Worse since the shelf started holding relay publication, which is a hold this same change + /// introduced: an arrival nothing is going to dispatch was withholding every checkpoint from + /// this device for the rest of the process. + /// + /// Through admit(), not dispatch(). A port is allowed to retain the same continuation it + /// declined, so both it and this class can be holding one copy each -- and installing the seam + /// re-offers the port's while this drains ours. admission is where (origin, sequence) + /// deduplication lives, so it is what makes the second copy a no-op; dispatching straight past + /// it ran the listeners and the provider twice on one arrival. + /// + /// Snapshotted and cleared BEFORE any of it is admitted, because admit() lands back in + /// placeOnOffer() for anything a listener defers -- so draining a live collection would either + /// re-admit what was just put back or lose it. + private static void drainPendingOffers() { + if (!enabled) { + return; + } + AppState[] pending = new AppState[shelved.size() + (parked == null ? 0 : 1)]; + int count = 0; + if (parked != null) { + pending[count++] = parked; + } + Iterator i = shelved.values().iterator(); + while (i.hasNext()) { + pending[count++] = i.next(); + } + parked = null; + shelved.clear(); + // OLDEST first, so the newest is the one left on offer once they have all been through. + // By the ORIGIN's clock, which is the only ordering two devices share -- sequences are + // per-device counters and comparing one against another's says nothing. + // + // Selection sort rather than Collections.sort: this runs on the core API surface, where + // what exists is what vm/JavaAPI and Ports/CLDC11 both define, and the count here is the + // number of the user's devices. + for (int a = 0; a < count - 1; a++) { + int oldest = a; + for (int b = a + 1; b < count; b++) { + if (pending[b].getTimestamp() < pending[oldest].getTimestamp()) { + oldest = b; + } + } + AppState swap = pending[a]; + pending[a] = pending[oldest]; + pending[oldest] = swap; + } + for (int a = 0; a < count; a++) { + if (!enabled) { + // A listener that ran during this drain is allowed to turn continuity off, and + // what follows the disable() must not then be admitted into it. + return; + } + admit(pending[a]); + } + } + /// Hands the port a callback. /// /// Installing one is ALSO how a port is asked to re-offer a continuation it declined earlier @@ -1623,6 +1675,19 @@ public static void clear() { // choice here would make every arrival AFTER the clear be dropped instead of held for // the enable() that is about to come -- and an arrival after the clear is not from // before it. + // + // The window is THIS CALL, and that is a contract rather than an accident: setCallback() + // requires a port to offer a held continuation before it returns. It has to be the call, + // because a held continuation reaches the seam by exactly the same route a brand new one + // does and carries nothing that tells them apart -- so the framework cannot bind the + // discard to the cleared session instead. A window that outlasted this call would start + // eating the arrivals meant for the account now signing in, which is the other half of + // what clear() promises and the reason it is not simply "continuity off". + // + // A port that answered later would have its pre-logout activity taken for a new one. That + // is a port failing its side of the contract, not a case for this code to guess at: the + // guess that would cover it -- drop everything for a while after a logout -- is exactly + // the behaviour the paragraph above rules out. discardHeldArrival = true; try { installCallback(true); diff --git a/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java index 211da94e4e7..f09f92c7163 100644 --- a/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java +++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java @@ -113,6 +113,27 @@ public interface ContinuityBridge { /// is what recovers a Handoff that cold-launched the app before anything was listening -- is /// relying on exactly that, so a framework that installed strictly once would strand it. /// + /// A held continuation offered in response to this call MUST be offered BEFORE this method + /// returns. Not a style note -- the framework's logout depends on it, and it is the one + /// ordering requirement this interface makes. + /// + /// `Continuity.clear()` empties the port as part of ending a session, and it does that by + /// installing a callback that discards whatever comes back. The window in which it discards + /// is this call, because the framework has no other way to draw the line: a held continuation + /// reaches `ContinuityCallback.continuationReceived` by exactly the same route a brand new one + /// does, carrying nothing that distinguishes them. A port that answered later would have its + /// pre-logout activity taken as a new arrival and restored into the account that just signed + /// in. + /// + /// Widening the window instead would break the other half of the same promise. `clear()` is a + /// logout, not "continuity off", and it deliberately leaves an enabled framework enabled, so a + /// continuation that genuinely arrives after it -- for the account now signing in -- has to be + /// delivered. Any window that outlasts the call starts eating those. + /// + /// Every port here already satisfies this: the iOS bridge hands its pending activity over + /// inline, and a bridge holding nothing satisfies it trivially. Writing it down is what stops + /// the next one from being the exception. + /// /// #### Parameters /// /// - `callback`: the seam, never null 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 index d6b9fa99bed..0030050bfb5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -8065,6 +8065,120 @@ public void setCallback(ContinuityCallback c) { } } + /** + * clear() empties the port of a held continuation, and does NOT stop the next one. + * + *

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

+ * + *

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

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

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

+ * + *

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

+ */ + @EdtTest + public void enableDrainsEveryArrivalHeldBeforeItNotJustTheLast() { + // Staged BEFORE anything enables continuity, which is the whole point: these reach the + // seam while the application has said nothing, so the framework holds them itself. + ContinuityCallback c = Continuity.callbackForTest(); + long base = System.currentTimeMillis() - 5000L; + Map first = new HashMap(); + first.put("note", "from the phone"); + c.continuationReceived(Continuity.getActivityType(), StateCodec.toMap(new AppState() + .setPayload(first).setDeviceId("phone").setSequence(1L).setTimestamp(base))); + flushSerialCalls(); + Map second = new HashMap(); + second.put("note", "from the tablet"); + c.continuationReceived(Continuity.getActivityType(), StateCodec.toMap(new AppState() + .setPayload(second).setDeviceId("tablet").setSequence(1L) + .setTimestamp(base + 1000L))); + flushSerialCalls(); + + final List seen = new ArrayList(); + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen.add(state.getDeviceId()); + return true; + } + }); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.enable(); + flushSerialCalls(); + flushSerialCalls(); + flushSerialCalls(); + + assertTrue(seen.contains("tablet"), + "the arrival in the slot was never dispatched by the enable: " + seen); + assertTrue(seen.contains("phone"), + "the arrival displaced onto the shelf before enable() was never dispatched, so " + + "its listeners and provider never saw it and it went on holding every " + + "relay checkpoint behind it: " + seen); + } + static class RecordingProvider implements StateProvider { final Map saved = new HashMap(); Map restored; From 25e7dd27fd7f2a477aeebd4076c484246ad672c5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:40:38 +0300 Subject: [PATCH 128/140] Pair the logout, refuse a negative timestamp, and compare against the stack that was installed Three findings, all real. clear() alone is not a logout, and this documents the pairing rather than changing what clear() means. It forgets the account's data and deliberately leaves continuity ON, because forgetting state and turning the feature off are two different things: an app is entitled to do the first without the second -- a "start over" that is not a sign-out -- and making this imply the other would stop continuity dead for every app that used it that way and never called enable() again. The consequence, unpaired, is that a continuation arriving while the login screen is up reaches a framework that is still listening and is valid by every test this class makes -- it came AFTER the clear -- so the signed-out account's routes and payload are restored over the login screen. disable() closes that gap and enable() at login reopens it. Said on clear(), in the guide, and in the logout snippet, which is what developers copy; the test asserts the pairing works and the probe without it reproduces the leak verbatim. A negative timestamp is now refused while decoding. Zero is the documented "carries no time" and isTooOld() reads anything not positive that way, so a negative ts from a custom relay or a compatibility sender was a state that could never expire, whatever maxAge was configured -- an expired checkout restorable for the life of the install. Refused rather than clamped, for the reason the sequence check gives: a value this codec silently repaired would differ from what the sender believes it sent. It is also where the arithmetic stays safe, since Long.MIN_VALUE would overflow the subtraction and the only reason it does not today is the positive-guard this same value hides behind. The session-ended branch compared the live stack against the REQUESTED routes, which was my own error from the round that added it. restoreStack() drops a path this build no longer registers -- the tolerance that lets an old checkpoint restore what it still can -- so what it installs is a SUBSEQUENCE of what it was given, equal only when nothing was skipped. One skipped route made the comparison answer "the application has navigated" for a stack the application had not touched, and the abort left the restored entries in Navigation: getCurrent() and back() disagreeing with the display, and those entries exposed again on a later enable. A subsequence and not a subset, because order is what separates the two cases: anything the application navigated to is a path this restore never asked for, so it breaks the sequence. The sibling comparison a few lines down was checked rather than assumed to match. It asks whether the application navigated during the rebuild and answers by checkpointing the real stack, so a skipped route makes it fire where it need not and write what is already true. Left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 48 +++++++- .../com/codename1/continuity/StateCodec.java | 18 +++ .../continuity/ContinuitySnippets.java | 12 ++ .../State-Restoration-And-Continuity.asciidoc | 14 ++- .../continuity/AppStateWireTest.java | 45 +++++++ .../continuity/LocalContinuityTest.java | 114 ++++++++++++++++++ 6 files changed, 247 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 8c9c57108a6..d291d204a4f 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1350,7 +1350,7 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // // Same rule as the two rollbacks in Navigation: undo what this restore installed, and // leave what application code chose afterwards. - if (currentRoutes().equals(routes)) { + if (isStillTheRestoredStack(currentRoutes(), routes)) { try { clearingStack = true; Navigation.clearStack(); @@ -1648,7 +1648,21 @@ public void run() { /// 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 + /// Belongs on your logout path, PAIRED WITH disable(), and enable() on the way back in. + /// + /// This call alone is not a logout. It forgets the account's data and deliberately leaves + /// continuity switched ON, because forgetting state and turning the feature off are two + /// different things: an application is entitled to do the first without the second -- a + /// "start over" that is not a sign-out -- and making this imply the other would stop + /// continuity dead for every app that used it that way and never called enable() again. + /// + /// The consequence, if it is not paired: a continuation that arrives while the login screen is + /// up reaches a framework that is still listening and still enabled. It is a valid arrival by + /// every test this class makes -- it came AFTER the clear, so it is not from the session that + /// ended -- so the signed-out account's routes and payload are restored over the login screen + /// and written to storage. disable() is what closes that gap; enable() at login reopens it. + /// + /// The advertised activity outlives the app's own screen, so an /// account's work would otherwise stay offered to the devices around it after the user signed /// out -- and a queued relay publish would have gone out later under whatever credentials the /// relay returned by then, which after a logout is the NEXT account's. @@ -1823,6 +1837,36 @@ private static void endRelaySession() { // Internals // ------------------------------------------------------------------ + /// Whether the live stack is still the one this restore installed, and nothing else. + /// + /// Not equality with the REQUESTED routes, which is what this asked before and got wrong the + /// moment a route was skipped. restoreStack() drops a path the build no longer registers -- + /// deliberately, that is the tolerance that lets an old checkpoint still restore what it can + /// -- so what it installs is a SUBSEQUENCE of what it was given, and the two are equal only + /// when nothing was skipped. Comparing for equality then answered "the application has + /// navigated" for a stack the application had not touched, and the abort left the restored + /// entries in Navigation: getCurrent() and back() disagreeing with what is on screen, and + /// those entries exposed again if continuity is re-enabled. + /// + /// A subsequence, not a subset, because order is the part that separates the two cases. + /// Anything the application navigated to is a path this restore did not ask for -- a login + /// screen, a safe list -- so it breaks the subsequence, and a stack that is still in the + /// restore's own order with only gaps in it is the restore's own. + private static boolean isStillTheRestoredStack(List live, List requested) { + int at = 0; + for (int i = 0; i < live.size(); i++) { + String path = live.get(i); + while (at < requested.size() && !requested.get(at).equals(path)) { + at++; + } + if (at == requested.size()) { + return false; + } + at++; + } + return true; + } + private static List currentRoutes() { List paths = new ArrayList(); List stack; diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java index 3989e204fe8..5af0fef5ee9 100644 --- a/CodenameOne/src/com/codename1/continuity/StateCodec.java +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -371,6 +371,24 @@ private static void requireKnownTypes(Map m) throws IOException requireType(m, KEY_TITLE, String.class, "a string"); requireNumberLike(m, KEY_SEQUENCE); requireNumberLike(m, KEY_TIMESTAMP); + // And a timestamp that is not in the PAST-or-absent shape the rest of the framework reads + // it as. Zero is the documented "this state carries no time", and isTooOld() reads + // anything not positive that way -- so a negative one is a state that can never expire, + // whatever maxAge the application configured. An expired checkout or a released booking + // hold would go on being restorable for the life of the install, which is the one thing + // maxAge exists to stop. + // + // Refused HERE rather than clamped, for the reason the sequence check gives: a value this + // codec silently repaired would differ from what the sender believes it sent, and the two + // sides then disagree about a state neither can see. It is also where the arithmetic + // stays safe -- Long.MIN_VALUE would overflow a subtraction, and the only reason it does + // not today is the positive-guard that this same malformed value hides behind. + if (asLong(m.get(KEY_TIMESTAMP)) < 0) { + throw new IOException("The continuity relay returned a document whose \"" + + KEY_TIMESTAMP + "\" is negative. Zero means a state carries no time and " + + "anything below it means nothing at all, so it is refused rather than read " + + "as a state that can never expire."); + } } /// Every ELEMENT of the route array, not just the array. 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 index 48b71a17770..c53fb518b1a 100644 --- 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 @@ -163,7 +163,19 @@ public void describeWhatThisDeviceCanDo() { // tag::logout[] public void onLogout() { + // Both, and in this order. clear() forgets the account's data; disable() closes the + // door behind it. clear() on its own leaves continuity ON, so a continuation that + // arrives while your login screen is up is a valid arrival to a framework that is + // still listening -- and the signed-out account's routes and payload get restored + // over it. Continuity.clear(); + Continuity.disable(); + } + + public void onLogin() { + // And open it again. Continuity stays off until you say otherwise, which is what + // makes the gap above safe. + Continuity.enable(); } // end::logout[] diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc index 5a7acf8fbbc..81423678cf7 100644 --- a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -182,8 +182,9 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/continuity 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 +Put `Continuity.clear()` *and* `Continuity.disable()` on your logout path, and +`Continuity.enable()` on your login path. The advertised activity outlives your +app's own screen, so an account's work would otherwise stay on offer to the devices around it after the user signed out -- and anything still queued for the relay would have gone out later under the next account's credentials, because a relay reads its token when the request runs: @@ -193,6 +194,15 @@ relay reads its token when the request runs: include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0] ---- +WARNING: `clear()` alone is not a logout. It forgets the account's data -- the +stored checkpoint, the advertised activity, anything queued for the relay -- and +it deliberately leaves continuity switched ON, because forgetting state and +turning the feature off are two different things and an app is entitled to do the +first without the second. That means a continuation arriving while your login +screen is up reaches a framework that is still listening, and the signed-out +account's routes and payload are restored over it. `disable()` is what closes +that gap, and `enable()` at login is what reopens it. + === The synced store `com.codename1.continuity.sync.SyncedStore` is the slow, patient half: a handful 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 index 325844ed814..1a6d1e10cc4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -29,6 +29,7 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -215,6 +216,50 @@ public void anUntaggedValueFromElsewhereIsPassedThrough() throws Exception { * a null LIST ELEMENT, which shifts every index after it -- so the payload arriving on the * other device is a different shape from the one that was sent. */ + /** + * A NEGATIVE timestamp is refused rather than read as "this state carries no time". + * + *

Zero is the documented absent value and isTooOld() reads anything not positive that way, + * so a negative ts arriving from a custom relay or a compatibility sender produced a state + * that could never expire -- whatever maxAge the application had configured. An expired + * checkout or a released booking hold would go on being restorable for the life of the + * install, which is the one thing maxAge exists to stop.

+ * + *

Refused at the wire boundary rather than clamped, for the reason the sequence check + * gives: a value this codec silently repaired would differ from what the sender believes it + * sent, and the two sides then disagree about a state neither can see.

+ */ + @Test + public void aNegativeTimestampIsRefused() { + String doc = "{\"routes\":[\"/a\"],\"device\":\"phone\",\"seq\":\"3\"," + + "\"ts\":\"-1\"}"; + + assertThrows(IOException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + StateCodec.fromJson(doc); + } + }, "a negative timestamp was accepted, so the state can never expire"); + } + + /** + * And the extreme of the same value, which is also where the arithmetic would go wrong. + * + *

Long.MIN_VALUE overflows the subtraction isTooOld() would make, and the only reason it + * does not today is the positive-guard that this same malformed value hides behind. Refusing + * it at the boundary is what keeps both true at once.

+ */ + @Test + public void theMostNegativeTimestampIsRefused() { + String doc = "{\"routes\":[\"/a\"],\"device\":\"phone\",\"seq\":\"3\"," + + "\"ts\":\"" + Long.MIN_VALUE + "\"}"; + + assertThrows(IOException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + StateCodec.fromJson(doc); + } + }, "Long.MIN_VALUE was accepted as a timestamp"); + } + @Test public void aNullPayloadValueIsRefusedWithItsKey() { final Map payload = new HashMap(); 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 index 0030050bfb5..25b743e7622 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -5558,6 +5558,69 @@ public void aParkedArrivalIsDroppedByAFirstDisable() { + "the enable() that followed it"); } + /** + * A restore whose session ends mid-way empties the stack it installed, even when a route was + * SKIPPED. + * + *

restoreStack() drops a path this build no longer registers -- deliberately, it is the + * tolerance that lets an old checkpoint restore what it still can -- so what it installs is a + * subsequence of what it was asked for, equal to it only when nothing was skipped. The abort + * compared the live stack against the REQUESTED routes, so one skipped path made it answer + * "the application has navigated" for a stack the application had not touched, and the + * restored entries were left in Navigation: getCurrent() and back() disagreeing with what is + * on screen, and those entries exposed again if continuity is re-enabled.

+ */ + @EdtTest + public void aSessionEndedMidRestoreEmptiesTheStackEvenWhenARouteWasSkipped() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + final Form dashboard = new Form("dashboard"); + dashboard.show(); + flushSerialCalls(); + + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + if ("/gone".equals(url)) { + // A route this build no longer registers. Skipped, not fatal. + return null; + } + Form f = new Form(); + f.setTitle(url); + if ("/orders/17".equals(url)) { + f.addShowListener(new com.codename1.ui.events.ActionListener() { + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + // Ends the session and goes NOWHERE, which is the case that separates + // "the application navigated" from "the restore's own stack". + Continuity.disable(); + } + }); + } + return f; + } + }); + try { + Map payload = new HashMap(); + payload.put("draft", "the previous account's"); + Continuity.restore(new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/orders", "/gone", "/orders/17")) + .setDeviceId("some-other-device") + .setSequence(171L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertTrue(Navigation.getStack().isEmpty(), + "the restore's own entries were left in Navigation after the session ended, " + + "because one skipped route made the live stack unequal to the routes " + + "that were asked for: " + Navigation.getStack()); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** * A login form the logout callback put up is not replaced by the screen the restore started * from. @@ -8179,6 +8242,57 @@ public boolean stateReceived(AppState state) { + "relay checkpoint behind it: " + seen); } + /** + * The documented logout flow closes the gap in front of the login screen. + * + *

clear() alone is not a logout and deliberately does not try to be: it forgets the + * account's data and leaves continuity switched ON, because forgetting state and turning the + * feature off are two different things and an app is entitled to do the first without the + * second. The consequence is that a continuation arriving while the login screen is up reaches + * a framework that is still enabled, and is valid by every test this class makes -- it came + * AFTER the clear, so it is not from the session that ended -- so the signed-out account's + * routes and payload get restored over the login screen and written to storage.

+ * + *

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

+ */ + @EdtTest + public void theDocumentedLogoutFlowClosesTheGapBeforeTheLoginScreen() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Continuity.enable(); + flushSerialCalls(); + + // The logout path, as the guide and the javadoc prescribe it. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + provider.restored = null; + + // A continuation lands while the login screen is up. It is not from the ended session -- + // it arrived after the clear -- so nothing else in this class refuses it. + Map payload = new HashMap(); + payload.put("note", "the signed-out account's work"); + ContinuityCallback c = Continuity.callbackForTest(); + assertTrue(c.continuationReceived(Continuity.getActivityType(), + StateCodec.toMap(new AppState() + .setPayload(payload).setDeviceId("phone").setSequence(7L) + .setTimestamp(System.currentTimeMillis()))), + "the arrival was not claimed, so the port goes on holding work for an account " + + "that has signed out"); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "an arrival that landed in front of the login screen was restored, so the " + + "signed-out account's work is on the next user's screen"); + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "the arrival is still on offer, so the enable() at login will apply it"); + } + static class RecordingProvider implements StateProvider { final Map saved = new HashMap(); Map restored; From 9a634011e99be16402117cb6cc473134a7ef3dac Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:48:58 +0300 Subject: [PATCH 129/140] Read a string declaration structurally, refuse a duplicated continuity type, keep the plain reads The open-source half of the builder findings, plus two review threads answered in code rather than changed. topLevelPlistString decided "is this a string" with a literal startsWith. "" opens a string exactly as "" does, and a comment or a processing instruction may sit between the key and its value -- all three are ordinary plist, and all three answered "not a string", so withContinuityActivityType refused the declaration as non-string and FAILED A CORRECT BUILD. It now asks for the element's NAME through nextElementName and takes the content between the structural tag boundaries, which is the rule plistElementIndex was given for ""; this was the last literal check beside it. Two live root CN1ContinuityActivityType declarations are refused, for the reason NSUserActivityTypes and UIApplicationSceneManifest already are: a property list takes the LAST of a duplicated key while every lookup here answers with the first, so an agreeing first declaration was left alone while the delegate reads a different second one. The scalar case was left out of the round that did the array; it is the same trap one key over. Three findings are answered where they arise instead. The injected value stays TRIMMED. Foundation does preserve padding inside a , so a padded declaration compares equal here while the delegate reads the padded one -- but this method is the shared reader for every top-level plist string in the builder, entitlement values and bundle ids included, and making it significant-whitespace changes what all of them accept for the sake of an activity type written with spaces around it. The lifecycle generation stays a plain field. This framework is single threaded on the event thread, the generation is written there and nowhere else, and the value compared is sampled ONCE when an activity arrives rather than re-read in a loop. No port calls the seam from a background thread that could cache it -- iOS is called per activity by the OS, which is itself a synchronizing hand-off; Android never reaches it; the simulator calls on the event thread. The direction of the error is the safe one besides: a stale generation DROPS an arrival, which the origin re-advertises and the relay still holds, where the opposite mistake restores an ended session's work into the account that replaced it. The iOS callback field stays a plain read for the same reason and one more: if that thread did see null, the arrival is RETAINED in pendingType/pendingJson and setCallback drains those inline on every install, so the theoretical race costs a delivery deferred to the next install rather than a lost activity -- and the machinery to close it would be a lock on the path the OS calls for every Handoff. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 14 ++++++ .../impl/ios/IOSContinuityCallbacks.java | 7 +++ .../com/codename1/builders/IPhoneBuilder.java | 49 +++++++++++++++++-- .../IPhoneBuilderContinuityPlistTest.java | 47 ++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index d291d204a4f..bb431f43f80 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -3803,6 +3803,20 @@ private boolean decide(Map userInfo, int arrivedIn) { // clear() or disable() ran between the arrival and this decision. Claimed, so the // port lets go: the state belongs to a session that has ended and nothing is // going to want it. + // + // NOT volatile, and deliberately. This framework is single threaded on the event + // thread; `lifecycle` is written there and nowhere else, and the value compared + // here was sampled ONCE when the activity arrived rather than re-read in a loop. + // A review asked for safe publication on the theory that a bridge calling from a + // long-lived background thread could keep observing a stale generation across a + // clear-then-enable. No port does that -- iOS is called per activity by the OS, + // which is itself a synchronizing hand-off; Android never reaches this seam; the + // simulator's bridge calls on the event thread -- and the direction of the error + // is the safe one either way: a stale generation DROPS an arrival, which the + // origin re-advertises and the relay still holds, while the opposite mistake + // restores an ended session's work into the account that replaced it. Making the + // field volatile would put cross-thread machinery into core to make an unlikely + // failure fail in the worse direction. return true; } if (discardHeldArrival) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 5ffa412a128..9b881b5e7da 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -153,6 +153,13 @@ public static boolean nativeContinuation(String activityType, String userInfoJso /// Hands an arrival to the framework, or holds it. Called on whatever thread the activity /// arrived on; the framework marshals what it needs to. private static boolean deliverToFramework(String activityType, String userInfoJson) { + // A plain read, and not a synchronized or volatile one. A review asked for safe + // publication here on the theory that this thread might still see null after the event + // thread installed the callback. Read what happens if it does: the arrival is RETAINED in + // pendingType/pendingJson below and false is returned, which is the same answer a decline + // gives -- and setCallback() drains those inline on every install. So the theoretical + // race costs a delivery deferred to the next install, not a lost activity, and the + // machinery to close it would be a lock on the path the OS calls for every Handoff. ContinuityCallback c = callback; boolean claimed = false; if (c != null) { 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 055663de507..da66485c257 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 @@ -11723,6 +11723,19 @@ static String withContinuityActivityType(String inject, String continuityType) // Refused rather than left alone when it disagrees, exactly as CN1DocumentsAppGroup is, // because this is not a hint. An injected key naming a different type has the delegate // rejecting the application's own continuations while iOS goes on offering them. + // Two live root declarations of the key, refused for the reason NSUserActivityTypes and + // UIApplicationSceneManifest already are: a property list takes the LAST of a duplicated + // key while every lookup here answers with the first, so an agreeing first declaration + // would be left alone while the delegate reads a different second one -- the generated + // array advertising one type and the native side accepting another, which is Handoff + // silently dead. The scalar case was left out of the round that refused the array; it is + // the same trap on the neighbouring key. + if (inject != null + && plistMemberDuplicated(inject, 0, inject.length(), "CN1ContinuityActivityType")) { + throw new BuildException("ios.plistInject declares CN1ContinuityActivityType twice. A " + + "property list takes the last of a duplicated key, so this build cannot " + + "tell which value the delegate will read. Leave one of them."); + } String injected = topLevelPlistString(inject, "CN1ContinuityActivityType"); if (injected == null && declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) { // Declared, but not as a string. topLevelPlistString answers null for an array, a @@ -13072,15 +13085,34 @@ static String topLevelPlistString(String plist, String key) { return null; } String value = plist.substring(range[0], range[1]).trim(); - if (value.startsWith("" opens a string exactly as "" + // does, and a comment or a processing instruction may sit between the key and its value + // -- all three are ordinary plist, and a literal startsWith() answered "not a string" to + // every one of them. withContinuityActivityType() then refused the declaration as + // non-string and FAILED A CORRECT BUILD, which is worse than the silent mismatch this + // method's other readers would have got. + // + // The same structural rule the container tags a few hundred lines up already follow: + // plistElementIndex() was made to see "" for this reason, and this was the last + // literal check left beside it. + String name = nextElementName(value, 0); + if ("true".equals(name)) { return "true"; } - if (value.startsWith("") || !value.endsWith("")) { + if (!"string".equals(name)) { return null; } + int open = plistElementIndex(value, "string", 0); + int contentStart = open < 0 ? -1 : plistOpenTagEnd(value, open); + int contentEnd = contentStart < 0 + ? -1 : plistCloseElementIndex(value, "string", contentStart); + if (contentEnd < 0) { + return null; + } + value = "" + value.substring(contentStart, contentEnd) + ""; // The element's CONTENT through the shared resolver, not a slice of its serialization. // A plist author may spell a value with character references -- "com.example.app" -- // or wrap it in CDATA, or put a comment inside the element, and Foundation reads all @@ -13092,6 +13124,17 @@ static String topLevelPlistString(String plist, String key) { // The key side of this method already resolves the same way -- plistMemberRange compares // key names through this helper -- so the two halves were answering one question two // different ways. + // + // TRIMMED, and it stays trimmed. A review asked for the exact value on the grounds that + // Foundation preserves padding inside a , so " com.example.app " + // would compare equal to the unpadded type here and leave a fragment whose delegate reads + // the padded one. True, and not worth what closing it costs: this method is the shared + // reader for every top-level plist string in this builder -- entitlement values, bundle + // ids, the container identifier -- and making it significant-whitespace would change what + // all of them accept for the sake of an activity type somebody wrote with spaces around + // it. The failure it leaves is a Handoff that does not work in an app whose plist says + // something the author did not mean, which is diagnosable; the failure it would introduce + // is spread across every other key this reads. String content = WatchNativeBuilder.plistStringContentExact( value.substring("".length(), value.length() - "".length())); return content == null ? null : content.trim(); 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 index 56ce9f6a89b..126d8b369fe 100644 --- 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 @@ -256,6 +256,53 @@ void aCommentedOutActivityTypesDeclarationIsNotADuplicate() throws BuildExceptio IPhoneBuilder.requireSingleUserActivityTypes(inject); } + /** + * A declaration spelled with ordinary plist formatting is still a string declaration. + * + *

{@code } opens a string exactly as {@code } does, and a comment may sit + * between the key and its value. Both are ordinary plist; a literal startsWith() answered "not + * a string" to each, and withContinuityActivityType() then refused the declaration as + * non-string and failed a build that was correct -- worse than the silent mismatch the same + * reader would produce elsewhere. The container tags a few hundred lines up were made + * structural for this exact reason; this was the last literal check beside them.

+ */ + @Test + void anOddlySpelledStringDeclarationIsStillAString() throws BuildException { + String spaced = "CN1ContinuityActivityType" + + CONTINUITY_TYPE + ""; + assertEquals(spaced, IPhoneBuilder.withContinuityActivityType(spaced, CONTINUITY_TYPE), + "a declaration written as was refused as a non-string value"); + + String commented = "CN1ContinuityActivityType" + + CONTINUITY_TYPE + ""; + assertEquals(commented, + IPhoneBuilder.withContinuityActivityType(commented, CONTINUITY_TYPE), + "a comment between the key and its value made the value unreadable"); + } + + /** + * Two live root CN1ContinuityActivityType declarations are refused rather than half-read. + * + *

The same trap NSUserActivityTypes and UIApplicationSceneManifest are already refused for, + * on the neighbouring key: a property list takes the LAST of a duplicated key while every + * lookup here answers with the first, so an agreeing first declaration is left alone while the + * delegate reads a different second one. The generated array then advertises one type and the + * native side accepts another, which is Handoff silently dead.

+ */ + @Test + void twoLiveContinuityTypeDeclarationsAreRefused() { + String inject = "CN1ContinuityActivityType" + CONTINUITY_TYPE + + "CN1ContinuityActivityType" + + "com.other.app.continuity"; + + try { + IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE); + fail("a duplicated CN1ContinuityActivityType must not be read from the first one"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains("twice"), expected.getMessage()); + } + } + /** * A NSUserActivityTypes whose value is not an array is refused once a continuity type depends * on it. From d62e41914368e1fe4ec5e26b362fe13f94156951 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:05:22 +0300 Subject: [PATCH 130/140] Release the publication hold from the shelf, refuse an intent that claims the continuity type Two findings, and the prose the guide gate caught. The publication hold is released beside every settle, and every one of them was keyed to the SLOT emptying. A listener that defers two arrivals keeps its own references to both; settling the parked one empties the slot, and settling the shelved one is then done by handing that reference straight to acknowledge(), which is the documented handle-it-yourself shape and never touches the slot. So the shelf let go of the arrival and nothing let go of the publisher, and the queued checkpoint sat until some unrelated later one happened to start it. Enumerated rather than patched where it was reported: four sites settle the shelf, and three of them were wrong. The expiry path already released unconditionally. The commit path and acknowledge() now release when the shelf moved and the slot did not. The tombstone path does the same and keeps deferring to a coalesced read that is still owed -- releasing there would start the POST ahead of the GET, against a relay that holds one document, which is the ordering that path already existed to protect. That is the second finding of this shape from the shelf, and the shape is worth naming: adding a SECOND holder for publication means every path that empties either one has to release. Not the one the report happened to find. An App Intent may no longer declare the continuity activity type as its id. An intent's id is published as its NSUserActivity activityType verbatim and the continuity type goes into the same array, so declaring both leaves the native delegate nothing to tell them apart and whichever handler looks first claims the other's activity -- Handoff resuming into an intent's screen, or an intent invocation restoring a route stack, with nothing logged. Refused rather than renamed: the id is the application's, published to the system and possibly already donated on a device, so a build that quietly changed it would break the donations already out there. The developer guide's new warning tripped Vale on two contractions and an adverb. Fixed in the prose rather than added to the vocabulary, since none of the three was a term of art. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 40 ++++++++++++-- .../State-Restoration-And-Continuity.asciidoc | 14 ++--- .../com/codename1/builders/IPhoneBuilder.java | 46 ++++++++++++++++ .../IPhoneBuilderContinuityPlistTest.java | 31 +++++++++++ .../continuity/LocalContinuityTest.java | 53 +++++++++++++++++++ 5 files changed, 172 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index bb431f43f80..914f99d76d4 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -1088,12 +1088,18 @@ public static boolean restore() { // `failed` is the distinction `shown` cannot make. False means both "there was no form to // show, and that is success" and "this did not work", which need opposite handling here -- // the same conflation that put two flags in capture() and in checkpoint(). - settleShelved(state); + boolean shelfSettled = settleShelved(state); if (supersedesParked(state)) { parked = null; // The slot is what holds a publication back; the decision has been made, so anything // waiting on it can go out now. startPublisher(); + } else if (shelfSettled) { + // The SHELF held it too, and settling a shelved arrival without touching the slot is + // an ordinary path: a listener keeps its own reference to what it deferred and + // acknowledges that, rather than promoting it first. startPublisher() rechecks + // whatever else is still on offer, so this cannot release a hold that is still owed. + startPublisher(); } return shown; } @@ -2456,7 +2462,7 @@ private static void admit(final AppState state) { // it, and the publication hold would go on withholding this device's checkpoints // behind it. Same shape as acknowledge() and expiry -- another way an arrival ends, // and every one of them has to release the slot. - settleShelved(state); + boolean shelfSettled = settleShelved(state); AppState waiting = parked; if (waiting != null && state.getDeviceId().equals(waiting.getDeviceId()) && waiting.getSequence() <= state.getSequence()) { @@ -2475,6 +2481,14 @@ private static void admit(final AppState state) { } else { startPublisher(); } + } else if (shelfSettled) { + // The tombstone settled a SHELVED state of its own and left the slot alone. Same + // release, and the same deference to a coalesced read that is still owed. + if (pollAgain) { + publishRequested = true; + } else { + startPublisher(); + } } // Durably, and here rather than through commit(). Consuming a tombstone is the one // arrival that CANNOT fail -- there is no payload to hand over and no route to @@ -2868,15 +2882,26 @@ private static void purgeShelf() { /// acknowledgement, or a tombstone from that origin finishes its shelved state too. Missing /// this would leave work on offer that the origin has already moved past, and hold this /// device's checkpoints behind it. - private static void settleShelved(AppState state) { + /// #### Returns + /// + /// true when this settled a shelved arrival, so the caller knows a publication hold may have + /// just been released. The shelf holds checkpoints back exactly as the slot does, and the + /// application can settle a SHELVED state directly -- a listener that kept its own reference + /// to the arrival it deferred and acknowledges that, rather than promoting it through + /// getRestorableState() first. The release beside every one of these calls is keyed to the + /// SLOT emptying, so nothing let the publisher go on that path and the queued checkpoint sat + /// until some unrelated later one happened to start it. + private static boolean settleShelved(AppState state) { String origin = state.getDeviceId(); if (origin == null || origin.length() == 0) { - return; + return false; } AppState kept = shelved.get(origin); if (kept != null && kept.getSequence() <= state.getSequence()) { shelved.remove(origin); + return true; } + return false; } /// Whether this state has already been marked handled. @@ -3124,7 +3149,7 @@ private static void noteActedOn(AppState state) { recordDurable(from, seq); } } - settleShelved(state); + boolean shelfSettled = settleShelved(state); if (supersedesParked(state)) { // The application has dealt with this arrival -- acknowledge() is the documented way // to decline one -- so the slot must not go on offering it through @@ -3133,6 +3158,11 @@ private static void noteActedOn(AppState state) { // state has a durable mark, so overwriting the relay's copy is now safe. parked = null; startPublisher(); + } else if (shelfSettled) { + // Settled off the SHELF without the slot changing, which is what a listener that kept + // its own reference to a deferred arrival does. The shelf holds publication too, so + // the hold has to be released here as well. + startPublisher(); } // Recorded before the write, because it is not about the write. What this process has // dealt with is true whether or not the mark reaches storage or survives the size budget. diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc index 81423678cf7..0c092a4bf8d 100644 --- a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -194,14 +194,14 @@ relay reads its token when the request runs: include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0] ---- -WARNING: `clear()` alone is not a logout. It forgets the account's data -- the +WARNING: `clear()` alone isn't a logout. It forgets the account's data -- the stored checkpoint, the advertised activity, anything queued for the relay -- and -it deliberately leaves continuity switched ON, because forgetting state and -turning the feature off are two different things and an app is entitled to do the -first without the second. That means a continuation arriving while your login -screen is up reaches a framework that is still listening, and the signed-out -account's routes and payload are restored over it. `disable()` is what closes -that gap, and `enable()` at login is what reopens it. +it leaves continuity switched on by design, because forgetting state and turning +the feature off are two different things and an app is entitled to do the first +without the second. That means a continuation arriving while your login screen is +up reaches a framework that's still listening, and the signed-out account's routes +and payload are restored over it. `disable()` is what closes that gap, and +`enable()` at login is what reopens it. === The synced store 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 da66485c257..d484d519dc1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12074,6 +12074,51 @@ private static void requireArrayForContinuity(String continuityType, String what + "out so the build writes the whole array itself."); } + /// Refuses an App Intent whose id IS the continuity activity type. + /// + /// An intent's id is published as its NSUserActivity activityType verbatim -- see + /// userActivityTypesKey, which appends it unchanged -- and the continuity type goes into the + /// same array. Declare both and the array carries the string twice, which is untidy; the part + /// that matters is that the native delegate has nothing left to tell them apart, so whichever + /// handler looks first claims an activity meant for the other. Handoff resuming into an + /// intent's screen, or an intent invocation restoring a route stack, with nothing logged. + /// + /// Refused rather than renamed. The id is the application's, published to the system and + /// possibly already donated on a device, so a build that quietly changed it would break the + /// donations already out there; and the continuity type is what the delegate compiles in. + /// Naming the collision is the only repair that leaves both meanings intact. + /// + /// #### Parameters + /// + /// - `intents`: the parsed intents manifest + /// + /// - `continuityType`: the resolved activity type, or null when the app does not use it + /// + /// #### Throws + /// + /// - `BuildException`: when an intent publishes the continuity type as its own + static void requireNoIntentClaimsTheContinuityType(List> intents, + String continuityType) throws BuildException { + if (intents == null || continuityType == null || continuityType.length() == 0) { + return; + } + for (Map intent : intents) { + Object id = intent.get("id"); + if (!(id instanceof String) || !IOSAppIntentsBuilder.publishesUserActivity(intent)) { + // Only the ones that reach NSUserActivityTypes. An intent that donates nothing + // shares no namespace with continuity and is none of this check's business. + continue; + } + if (continuityType.equals(id)) { + throw new BuildException("An App Intent declares the id '" + id + "', which is " + + "the activity type this build publishes for continuity. Both are " + + "advertised through NSUserActivityTypes and the native side has " + + "nothing left to tell them apart, so one handler would claim the " + + "other's activity. Give the intent an id of its own."); + } + } + } + /// Refuses a fragment that declares NSUserActivityTypes twice at the root. /// /// The trap UIApplicationSceneManifest is already refused for, and it resolves the same way: a @@ -14765,6 +14810,7 @@ public boolean accept(File file, String string) { // unterminated comment, so everything after it was truncated and a live root key // beyond it went missing -- and this branch then appended a SECOND one. requireSingleUserActivityTypes(inject); + requireNoIntentClaimsTheContinuityType(intentsManifest, continuityActivityType); if (firstLiveRootIndex(inject, "NSUserActivityTypes") < 0) { inject += userActivityTypesKey(intentsManifest, continuityActivityType); } else { 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 index 126d8b369fe..a7d013bcc28 100644 --- 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 @@ -303,6 +303,37 @@ void twoLiveContinuityTypeDeclarationsAreRefused() { } } + /** + * An App Intent may not claim the continuity activity type as its own id. + * + *

An intent's id is published as its NSUserActivity activityType verbatim, and the + * continuity type goes into the same array. Declare both and the native delegate has nothing + * left to tell them apart, so whichever handler looks first claims an activity meant for the + * other -- Handoff resuming into an intent's screen, or an intent invocation restoring a route + * stack, with nothing logged either way.

+ */ + @Test + void anIntentMayNotClaimTheContinuityType() { + List> colliding = intents(CONTINUITY_TYPE); + + try { + IPhoneBuilder.requireNoIntentClaimsTheContinuityType(colliding, CONTINUITY_TYPE); + fail("an intent publishing the continuity activity type must not be accepted"); + } catch (BuildException expected) { + assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage()); + } + } + + /** + * And an ordinary intent beside continuity is untouched, or the guard would refuse every app + * that uses both features -- which is the ordinary case the key exists to serve. + */ + @Test + void anOrdinaryIntentBesideContinuityIsFine() throws BuildException { + IPhoneBuilder.requireNoIntentClaimsTheContinuityType( + intents("logWorkout", "com.example.app.sendMessage"), CONTINUITY_TYPE); + } + /** * A NSUserActivityTypes whose value is not an array is refused once a continuity type depends * on it. 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 index 25b743e7622..6374eba4c89 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7586,6 +7586,59 @@ public boolean stateReceived(AppState state) { "the in-window drain stopped dispatching, so nothing is ever handed over"); } + /** + * Acknowledging a SHELVED arrival directly releases the publication hold it was keeping. + * + *

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

+ */ + @EdtTest + public void acknowledgingAShelvedArrivalReleasesItsPublicationHold() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + final int before = r.sent.size(); + + AppState phone = foreign("phone", 1L); + AppState tablet = foreign("tablet", 1L); + Continuity.parkForTest(phone); + Continuity.parkForTest(tablet); + + // The slot's is settled first, which is the ordinary half. + Continuity.acknowledge(tablet); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), + "the shelved arrival is not holding the checkpoint, so there is no hold for the " + + "settle below to release and the rest of this proves nothing"); + + // And now the SHELVED one, straight from the listener's own reference. Nothing calls + // getRestorableState(), so the slot never changes. + Continuity.acknowledge(phone); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held after the last arrival was settled, because the " + + "release is keyed to the slot emptying and this one came off the shelf"); + } + /** * clear() empties the shelf, not only the slot. * From b1574be129b05f6d7f60585c2fd0a334fa8acf49 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:27:18 +0300 Subject: [PATCH 131/140] Drain both holders on the cold-launch path, keep pre-restore history, report an oversized payload Four findings: two fixes, one answered differently from how it was asked, and one factual correction. The cold-launch waiter dispatched the slot and left the shelf. Two devices can both reach the callback before the first form exists -- which is the situation the wait exists for -- and the second displaces the first onto the shelf, so that first arrival had nothing coming for it: never handed to a listener even with automatic restoration on, reachable only if the application called getRestorableState() by hand, and holding every relay publication behind it meanwhile. That is the third path in this class to need it, after the pre-enable drain and the settles, so the two drains now share one takeAllPendingOffers() instead of a second copy of the same logic. The rule the three of them share is worth stating once: a SECOND holder means every path that empties EITHER of them has to deal with both, and patching whichever path a report happens to name has now missed it three times. The session-ended abort could clear the user's own history. A route factory can end the session on its first call, before restoreStack() installs anything, and the live stack is then the pre-restore history -- which can coincide with a prefix of what was requested, live /home against a requested /home,/detail being ordinary rather than contrived. The subsequence test read that as restoration-owned and emptied it, and disable() is not a logout. Asking whether the stack changed at all since before the rebuild is what separates "installed a subset" from "installed nothing", and it needs no state that was not already to hand. An oversized continuation payload is now REPORTED, not refused. The harm is real -- the platform carries userInfo as a small dictionary, so a large one simply does not arrive while the local checkpoint and the relay both succeed, which is what makes it hard to see. But refusing needs a number this framework cannot source: Apple documents the payload as small and offers continuation streams for more without publishing a limit that fails cleanly, and rejecting on a guess would drop states that transfer perfectly well today. So it is measured with the framework's own portable stand-in and said once per session, which turns a silent failure into one with something to search for. The watchOS and tvOS comment claimed both APIs were unavailable there. Wrong, and checked rather than argued: Foundation declares NSUserActivity as watchos(2.0)/tvos(9.0) and NSUbiquitousKeyValueStore as watchos(9.0)/tvos(9.0). Leaving the natives out of those slices is a SCOPE decision -- what this feature ships and tests is phone-to-phone and phone-to-Mac -- and nothing misreports itself, because both capability queries answer false there, which is true of the build if not of the platform. Corrected to say that, with a note that turning either on means giving the synced store its own define rather than widening this one. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 135 +++++++++++++++--- .../CodenameOne_GLViewController.h | 19 ++- .../continuity/LocalContinuityTest.java | 107 ++++++++++++++ 3 files changed, 236 insertions(+), 25 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 914f99d76d4..7f78dca3784 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -157,6 +157,13 @@ public final class Continuity { /// means this is a count of the user's devices, and eight is past any real account. private static final int MAX_SHELVED = 8; + /// When a continuation payload is worth mentioning. Advisory only -- see + /// warnIfLargeForHandoff, which reports and never refuses. + private static final int HANDOFF_ADVISORY_CHARS = 3072; + + /// Whether the size above has already been reported this session. + private static boolean handoffSizeReported; + /// How long to wait for the application to produce its first form before giving up on a /// continuation that cold-launched it. A launch that never produces one is a broken /// application, and restoring minutes later into whatever the user is doing by then is worse @@ -383,6 +390,33 @@ private static void drainPendingOffers() { if (!enabled) { return; } + AppState[] pending = takeAllPendingOffers(); + int count = pending.length; + // takeAllPendingOffers() has already put them oldest first. + for (int a = 0; a < count; a++) { + if (!enabled) { + // A listener that ran during this drain is allowed to turn continuity off, and + // what follows the disable() must not then be admitted into it. + return; + } + admit(pending[a]); + } + } + + /// Empties BOTH holders and returns what they held, oldest first. On the EDT. + /// + /// Snapshotted and cleared before the caller does anything with the result, because both + /// dispatch() and admit() land back in placeOnOffer() for whatever a listener defers -- so + /// draining a live collection would either re-take what was just put back or lose it. + /// + /// Oldest first by the ORIGIN's clock, which is the only ordering two devices share: a + /// sequence is a per-device counter and comparing one against another's says nothing. That + /// leaves the newest as the one on offer once they have all been through. + /// + /// Selection sort rather than Collections.sort, because this is the core API surface -- what + /// exists is what vm/JavaAPI and Ports/CLDC11 both define -- and the count is the number of + /// the user's devices. + private static AppState[] takeAllPendingOffers() { AppState[] pending = new AppState[shelved.size() + (parked == null ? 0 : 1)]; int count = 0; if (parked != null) { @@ -394,13 +428,6 @@ private static void drainPendingOffers() { } parked = null; shelved.clear(); - // OLDEST first, so the newest is the one left on offer once they have all been through. - // By the ORIGIN's clock, which is the only ordering two devices share -- sequences are - // per-device counters and comparing one against another's says nothing. - // - // Selection sort rather than Collections.sort: this runs on the core API surface, where - // what exists is what vm/JavaAPI and Ports/CLDC11 both define, and the count here is the - // number of the user's devices. for (int a = 0; a < count - 1; a++) { int oldest = a; for (int b = a + 1; b < count; b++) { @@ -412,14 +439,7 @@ private static void drainPendingOffers() { pending[a] = pending[oldest]; pending[oldest] = swap; } - for (int a = 0; a < count; a++) { - if (!enabled) { - // A listener that ran during this drain is allowed to turn continuity off, and - // what follows the disable() must not then be admitted into it. - return; - } - admit(pending[a]); - } + return pending; } /// Hands the port a callback. @@ -1356,7 +1376,19 @@ private static boolean restore(final AppState state, boolean[] outFailed) { // // Same rule as the two rollbacks in Navigation: undo what this restore installed, and // leave what application code chose afterwards. - if (isStillTheRestoredStack(currentRoutes(), routes)) { + // Unchanged since before the rebuild means this restore installed NOTHING yet -- a + // route factory that ended the session on its first call, before restoreStack() put + // anything in place. The live stack is then the pre-restore history, and clearing it + // destroys navigation the user had before any of this started. disable() is not a + // logout, so there is nothing here that licenses throwing that away. + // + // The subsequence test alone could not see it: a pre-restore stack can coincide with + // a prefix of what was requested -- live /home against a requested /home,/detail is + // the ordinary case, not a contrived one -- and it read as restoration-owned. Asking + // whether anything changed first is what separates "the restore installed a subset" + // from "the restore installed nothing and this was already here". + List live = currentRoutes(); + if (!live.equals(routesBefore) && isStillTheRestoredStack(live, routes)) { try { clearingStack = true; Navigation.clearStack(); @@ -2051,6 +2083,19 @@ private static void publishContinuation(AppState state) { b.clearContinuation(); return; } + // Said once when the payload is large, and NOT refused. The platform carries a + // continuation's userInfo as a small dictionary -- Apple documents it for small + // payloads and offers continuation streams for anything more -- but publishes no hard + // byte limit that fails cleanly, so an oversized one simply does not arrive on the + // other device. The local checkpoint and the relay are unaffected, which is what + // makes it hard to see: everything works except the half that crosses the room. + // + // A REFUSAL would need a number this framework cannot source. Guessing one and + // rejecting on it would drop states that transfer perfectly well today, which is a + // worse failure than the one being reported. So the size is measured with the + // framework's own portable stand-in and reported once per session, turning a silent + // failure into one with something to search for. + warnIfLargeForHandoff(state); b.publishContinuation(getActivityType(), state.getTitle(), StateCodec.toMap(state)); } catch (Throwable t) { Log.e(t); @@ -3046,15 +3091,60 @@ private static void windowWaitFinished(long deadline) { } // Taken and cleared rather than compared against the state the waiter was started for. A // newer arrival while it waited is the one worth showing. - AppState waiting = parked; - parked = null; - if (waiting != null) { - dispatch(waiting); - } - // Whether it dispatched or was refused, the slot is no longer holding anything back. + // + // The SHELF as well as the slot, and for the third time in this class: two devices can + // both reach the callback before the first form exists, and the second displaces the + // first onto the shelf. Dispatching only the slot left that first arrival with nothing + // coming for it -- never handed to a listener even with automatic restoration on, and + // reachable only if the application called getRestorableState() by hand -- while it went + // on holding every relay publication behind it. + // + // The generalisation, since patching one path at a time has now missed three: a SECOND + // holder means every path that empties EITHER of them has to deal with both. The + // pre-enable drain, the settles, and this. + // + // dispatch(), not admit(): everything here has already been through admission, which is + // how it came to be parked. Snapshotted and cleared before any of it is dispatched, + // because a listener that defers lands straight back in placeOnOffer(). + AppState[] pending = takeAllPendingOffers(); + for (int i = 0; i < pending.length; i++) { + dispatch(pending[i]); + } + // Whether they dispatched or were refused, neither holder is keeping anything back now. startPublisher(); } + /// Reports a continuation payload big enough that the platform may not carry it. + /// + /// Advisory, not a limit: the threshold below is this framework's own conservative reading of + /// "small", not a constant Apple publishes, which is exactly why nothing is refused on it. + /// + /// Once per session. A checkpoint runs on every route change, so a per-publish message would + /// bury the log of an application that is simply carrying a lot of state -- and it is the + /// same state each time, so saying it again adds nothing. + private static void warnIfLargeForHandoff(AppState state) { + if (handoffSizeReported) { + return; + } + int size; + try { + size = StateCodec.encodedSize(state); + } catch (Throwable t) { + // Measuring must never be the thing that stops a publish. + Log.e(t); + return; + } + if (size <= HANDOFF_ADVISORY_CHARS) { + return; + } + handoffSizeReported = true; + Log.p("Continuity: this checkpoint encodes to " + size + " characters, which is larger " + + "than a continuation's userInfo is meant to carry. Saving and restoring on this " + + "device is unaffected, and so is any relay -- but the hand-off to a nearby " + + "device may not arrive. Put an identifier in the payload and fetch the rest on " + + "the other side."); + } + /// A fresh origin id, minted once per install and persisted. /// /// NOT Util.getUUID(), and the reason is worth the paragraph. Instantiating Util.UUID runs its @@ -3735,6 +3825,7 @@ static void reset() { deviceId = null; parked = null; shelved.clear(); + handoffSizeReported = false; dirty = false; waitingForWindow = false; applyingRestore = false; diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 10a112284a3..fef2be32d7c 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -242,9 +242,22 @@ 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. +// Continuity goes with them, as a SCOPE decision rather than an availability one, and the +// difference matters because the comment here used to claim the wrong thing. Both APIs exist on +// these platforms: Foundation declares NSUserActivity as watchos(2.0)/tvos(9.0) and +// NSUbiquitousKeyValueStore as watchos(9.0)/tvos(9.0). What this feature ships and tests is the +// phone-to-phone and phone-to-Mac case, so the natives are left out of the watch and TV slices +// rather than shipped untested. +// +// Nothing misreports itself as a result. isContinuationSupported() and isSyncedStoreSupported() +// both answer false on those slices, which is true of the build even though it is not true of +// the platform, and an application branches on those rather than on which device it is. The Java +// half is unaffected either way -- a watch app still saves and restores its own state, which is +// the half that needs no native support. +// +// Turning either on later means giving the synced store its own define rather than widening this +// one, since the two capabilities are advertised independently and only one of them has anything +// to do with Handoff. #if TARGET_OS_WATCH || TARGET_OS_TV #undef CN1_USE_INTENTS #undef CN1_APP_INTENTS_DECLARED 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 index 6374eba4c89..b444873569c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7639,6 +7639,113 @@ public void run() { + "release is keyed to the slot emptying and this one came off the shelf"); } + /** + * The cold-launch drain hands over EVERY arrival it was holding, not just the slot's. + * + *

Two devices can both reach the callback before the first form exists -- which is the + * situation the wait exists for -- and the second displaces the first onto the shelf. + * Dispatching only the slot left that first arrival with nothing coming for it: never handed + * to a listener even with automatic restoration on, reachable only if the application called + * getRestorableState() by hand, and holding every relay publication behind it meanwhile.

+ * + *

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

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

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

+ * + *

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

+ */ + @EdtTest + public void aSessionEndedBeforeAnythingWasInstalledKeepsThePreRestoreHistory() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + final Form home = new Form("home"); + home.show(); + flushSerialCalls(); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + // The history the user already has, put there the ordinary way. + Navigation.navigate("/home"); + flushSerialCalls(); + assertEquals(1, Navigation.getStack().size(), "the fixture never built a stack"); + + // Now a restore whose FIRST factory call ends the session before installing anything. + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Continuity.disable(); + return null; + } + }); + Map payload = new HashMap(); + payload.put("draft", "from the other device"); + Continuity.restore(new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/home", "/detail")) + .setDeviceId("some-other-device") + .setSequence(180L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertEquals(1, Navigation.getStack().size(), + "the pre-restore history was cleared by an abort, even though this restore " + + "installed nothing -- disable() is not a logout: " + + Navigation.getStack()); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + /** * clear() empties the shelf, not only the slot. * From 44e73e79bbd3d0d5767f6f73fcea7868602f492f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:43:30 +0300 Subject: [PATCH 132/140] Read the relay when a session begins setRelay() installs the transport and does not read it, and clear() and disable() end the session and drop any fetch in flight. So the enable() that comes with a login had the relay object still installed and nothing asking it anything: the account that just signed in did not see its own state from another device until the application happened to call pollRelay() or the app was resumed. Android's resume poll is a different event, which a login completed in the foreground never fires, and there is no automatic one on iOS at all. Taken by the framework rather than left to the application, which is the opposite of the call made for the clear()/disable() pairing a few commits ago, and the difference is worth stating. There, making clear() imply disable() would have broken a legitimate use -- forgetting state without signing out -- so the pairing was documented instead. Here there is no competing meaning: a session beginning IS the moment to read the relay, and this is the code that knows one began. The cost of being wrong about wanting it is nothing. pollRelay() guards itself on a relay being installed, defers behind a publish in flight because the relay holds one document per user, and coalesces a second poll from an application that also asks. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 16 +++++++ .../continuity/LocalContinuityTest.java | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 7f78dca3784..224bca64606 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -362,6 +362,22 @@ public void run() { } }); } + // And the RELAY, which is the third place a session's work can be waiting. + // + // setRelay() installs the transport and does not read it, and clear() and disable() end + // the session and drop any fetch in flight -- so the enable() that comes with a login had + // the relay object still installed and nothing asking it anything. The account that just + // signed in then did not see its own state from another device until the application + // happened to call pollRelay() or the app was resumed: Android's resume poll is a + // different event that a login completed in the foreground never fires, and there is no + // automatic one on iOS at all. + // + // Unlike the disable() that clear() needs, there is nothing here to leave to the + // application. A new session beginning is exactly the moment to read the relay, and this + // is the code that knows one began. pollRelay() guards itself on a relay being installed + // and defers behind a publish in flight, and a second poll from an application that also + // asks is coalesced -- so the cost of being wrong about wanting it is nothing. + pollRelay(); } /// Admits everything this class was holding before enable(), oldest first. On the EDT. 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 index b444873569c..13bbd1990b1 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7746,6 +7746,50 @@ public Form dispatch(String url) { } } + /** + * Enabling a session reads the relay, so a login finds the new account's work. + * + *

setRelay() installs the transport and does not read it, and clear() and disable() end the + * session and drop any fetch in flight. So the enable() that comes with a login had the relay + * still installed and nothing asking it anything: the account that just signed in did not see + * its own state from another device until the application happened to call pollRelay() or the + * app was resumed -- and Android's resume poll is a different event that a login completed in + * the foreground never fires, with no automatic one on iOS at all.

+ * + *

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

+ */ + @EdtTest + public void enablingASessionReadsTheRelay() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + + // The documented logout, which ends the session and drops anything in flight. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + final int before = r.fetches(); + + // And the login. + Continuity.enable(); + pause(400L); + + assertTrue(r.fetches() > before, + "enabling a new session never read the relay, so the account that just signed in " + + "does not see its own work from another device until something else " + + "happens to poll"); + } + /** * clear() empties the shelf, not only the slot. * From 7aa46c3af4e3e838932a12c8361802b4595709ad Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:06:18 +0300 Subject: [PATCH 133/140] Copy what the bridge says it copies, compare the granted iCloud container, drop a tri-state default Three findings, and the third is one where a test of mine was defending the bug. The simulator bridge handed out shallow copies at all three places it passes a payload across. AppState had already decided this for its own snapshot -- "a shallow copy left the snapshot sharing the application's own lists and maps ... a snapshot has to be a snapshot" -- and this class contradicted it. What it advertises is what the simulator shows and what tests assert on, so a caller reaching into a nested list was editing the record of what had been published, and the next simulateArrival() delivered the edit instead of the checkpoint. A double that shares state it says it copied is worse than one that is plainly wrong, because what it breaks is the test's ability to notice. ios.continuity.sync no longer declares a default. The hint has three states and def() can only describe two: unset means the bytecode scan decides, while an explicit true DECLARES the store and forces the entitlement and the preflight whatever the scan found -- which is a distinction the builder makes by reading the same hint with two different defaults. Declaring "true" told everything that reads the catalog those were the same thing, so a project that had merely never set it was presented as opted in, and the tooling would offer an iCloud entitlement the build would not have asked for. The doc text already said the right thing and the default contradicted it. The provisioning preflight now compares the container. It said "WHICH container it grants is not something this can answer from the key alone", which was true only because the parser reduced the entitlement to a boolean and threw the value away -- a limitation of my own making, described as if it were inherent. Worse, I had written a test asserting the profile is left alone in exactly the case that fails to sign, so the limitation had a guard defending it. The parser keeps the string, and the check compares when BOTH sides are literal: the value requested when a project names none is "$(TeamIdentifierPrefix)$(CFBundleIdentifier)", two Xcode variables this has no business expanding, and a profile may grant a wildcard. Comparing either would warn about configurations that sign perfectly well, and a preflight that cries wolf is one people stop reading. Two literals that differ is the case that is certain, and it is the ordinary way to get this wrong -- an app sharing a sibling's store is exactly when a container gets named by hand. Co-Authored-By: Claude Opus 5 (1M context) --- .../continuity/LocalContinuityBridge.java | 54 +++++++++++++++++-- .../codename1/build/shared/BuildHintsIos.java | 9 +++- .../maven/IOSProvisioningPreflight.java | 54 +++++++++++++++++-- .../maven/IOSContinuitySyncPreflightTest.java | 51 ++++++++++++++++-- .../continuity/LocalContinuityTest.java | 41 ++++++++++++++ 5 files changed, 197 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java index 8043264aae0..9cbf765584f 100644 --- a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -96,8 +96,7 @@ public boolean isContinuationSupported() { @Override public void publishContinuation(String activityType, String title, Map userInfo) { - Map copy = userInfo == null - ? null : new HashMap(userInfo); + Map copy = userInfo == null ? null : deepCopy(userInfo); publishedType = activityType; publishedTitle = title; publishedInfo = copy; @@ -134,7 +133,7 @@ public String getPublishedTitle() { /// /// a copy of the payload public Map getPublishedInfo() { - return publishedInfo == null ? null : new HashMap(publishedInfo); + return publishedInfo == null ? null : deepCopy(publishedInfo); } /// Delivers the currently advertised activity back to the app as though it had arrived from @@ -151,7 +150,7 @@ public boolean simulateArrival() { if (publishedType == null || publishedInfo == null) { return false; } - Map copy = new HashMap(publishedInfo); + Map copy = deepCopy(publishedInfo); copy.put("device", "simulated-device"); return simulateArrival(publishedType, copy); } @@ -522,4 +521,51 @@ private boolean writeIndex(List keys) { } return write(INDEX, sb.toString()); } + + /// A real copy of a payload, nested lists and maps included. + /// + /// AppState already made this decision for its own snapshot -- "a shallow copy left the + /// snapshot sharing the application's own lists and maps ... a snapshot has to be a snapshot" + /// -- and this class contradicted it at the three places it hands a payload across. What is + /// advertised here is what the simulator shows and what tests assert on, so a caller that + /// reaches into a nested list was editing the record of what had been published: the next + /// simulateArrival() then delivered the edit rather than the checkpoint. + /// + /// A test double that shares state it says it copied is worse than one that is simply wrong, + /// because what it breaks is the test's ability to notice. + /// + /// Its own copy rather than AppState's, which is private and belongs to a different package. + /// Fifteen lines here is a smaller price than widening that class's surface for an impl. + private static Map deepCopy(Map p) { + Map out = new HashMap(); + for (Map.Entry e : p.entrySet()) { + out.put(e.getKey(), copyValue(e.getValue())); + } + return out; + } + + private static Object copyValue(Object value) { + if (value instanceof List) { + List in = (List) value; + List out = new ArrayList(in.size()); + for (Object element : in) { + out.add(copyValue(element)); + } + return out; + } + if (value instanceof Map) { + Map in = (Map) value; + Map out = new HashMap(); + for (Map.Entry e : in.entrySet()) { + if (e.getKey() instanceof String) { + out.put((String) e.getKey(), copyValue(e.getValue())); + } + } + return out; + } + // Everything else a payload may carry is immutable -- String, the boxed primitives -- so + // sharing it is sharing a value, not a container. + return value; + } + } diff --git a/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 d66b5bd5e5f..482b5ea84c3 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -795,7 +795,14 @@ static void register(List h) { h.add(new Hint("ios.continuity.sync") .group(HintGroup.IOS) .type(HintType.BOOLEAN) - .def("true") + // NO default, because this hint has three states and def() can only describe two. + // Unset is its own answer -- the bytecode scan decides -- while an explicit true + // now DECLARES the store, forcing the entitlement and the provisioning preflight + // whatever the scan found. Declaring "true" here said the two were the same thing + // to everything that reads the catalog, so a project that had merely never set it + // was presented as having opted in, and the tooling would offer an iCloud + // entitlement the build would not have asked for. The doc below says which state + // does what. .platform("ios") .doc("Whether this project wants the iCloud key-value store behind " + "com.codename1.continuity.sync. Left unset the build decides from the " 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 ac1d8337d0d..a46ad951ca7 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 @@ -109,6 +109,28 @@ static class Profile { * none, because a profile that could not be parsed at all produces no Profile.

*/ boolean ubiquityKeyValueStore; + + /** + * The container that entitlement names, exactly as the profile spells it, or null. + * + *

Kept rather than reduced to the boolean above, because the boolean is what made the + * check unable to answer which container was granted -- a limitation the code then + * described as inherent when it was only self-inflicted. Codesigning rejects an app + * entitlement the profile's value does not cover, so a profile granting one container + * and a project naming another is a build that fails at signing.

+ */ + String ubiquityKeyValueStoreValue; + } + + /** + * Whether a container name is something two sides can be compared on. + * + *

An Xcode variable expands at build time and a wildcard covers a set, so neither is a + * value this can hold against another one. Only a plain literal is.

+ */ + private static boolean isLiteralContainer(String container) { + return container != null && !container.isEmpty() + && container.indexOf("$(") < 0 && container.indexOf('*') < 0; } /** A problem found before the build was sent: {@code message} is written for the user. */ @@ -280,9 +302,31 @@ static List checkContinuitySync(Properties settings, boolean release) { 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. + // Granted -- and now WHICH container, when both sides say so literally. + // + // Only then. The value the build requests when the project names none is + // "$(TeamIdentifierPrefix)$(CFBundleIdentifier)", two Xcode variables this has no + // business expanding, and a profile may grant a wildcard. Comparing either of those + // would produce warnings on configurations that sign perfectly well, which is worse + // than staying quiet: a preflight that cries wolf is one people stop reading. + // + // Two literals that differ is the case that is certain, and it is the ordinary way to + // get this wrong -- an app sharing a sibling's store, which is exactly when a project + // names a container by hand. + if (isLiteralContainer(override) && isLiteralContainer( + appProfile.ubiquityKeyValueStoreValue) + && !override.equals(appProfile.ubiquityKeyValueStoreValue)) { + problems.add(new Problem("This project asks for the iCloud key-value store " + + "container \"" + override + "\", and the provisioning profile \"" + + appProfile.name + "\" grants \"" + + appProfile.ubiquityKeyValueStoreValue + "\".\n" + + "Codesigning rejects an app entitlement the profile does not cover, so " + + "this build fails when it is signed rather than when it is sent.\n" + + "Either point ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier at the container the profile grants, or " + + "regenerate the profile from an App ID whose iCloud capability includes " + + "the one you want.", false)); + } return problems; } // Not granted AT ALL, and an explicit container does not rescue that: the builder puts the @@ -1095,6 +1139,10 @@ static Profile parse(byte[] raw) throws Exception { // makes the build declare. Element ubiquity = valueForKey(doc, "com.apple.developer.ubiquity-kvstore-identifier"); profile.ubiquityKeyValueStore = ubiquity != null; + if (ubiquity != null && "string".equals(ubiquity.getTagName())) { + String granted = ubiquity.getTextContent().trim(); + profile.ubiquityKeyValueStoreValue = granted.isEmpty() ? null : granted; + } profile.type = deriveType(doc); return profile; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java index b3c927c3284..b5e807f1a7c 100644 --- 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 @@ -160,19 +160,62 @@ public void aContinuityOnlyProjectIsNotWarnedAboutICloud() throws Exception { } /** - * 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. + * A profile granting one container while the project names another is a build that fails at + * codesigning, and this says so before it is sent. + * + *

This test used to assert the opposite, on the reasoning that WHICH container a profile + * grants could not be answered from the key alone. That was true only because the parser threw + * the value away -- a limitation the code then described as if it were inherent. Keeping the + * string makes the comparison ordinary, and an app sharing a sibling's store is exactly when a + * project names a container by hand and can name the wrong one.

*/ @Test - public void anExplicitContainerOnAGrantingProfileIsLeftAlone() throws Exception { + public void anExplicitContainerTheProfileDoesNotGrantIsWarnedAbout() throws Exception { Properties p = settings(profile("WithCloud", true)); p.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared"); + List problems = check(p); + + assertEquals("a container the profile does not grant was not reported", + 1, problems.size()); + assertTrue(problems.get(0).message, + problems.get(0).message.contains("ABCD1234.com.example.shared")); + assertTrue(problems.get(0).message, + problems.get(0).message.contains("ABCD1234.com.example.app")); + } + + /** + * The container the profile actually grants passes quietly, or the check above would fire on + * every correctly configured project that names its container explicitly. + */ + @Test + public void theContainerTheProfileGrantsIsLeftAlone() throws Exception { + Properties p = settings(profile("WithCloud", true)); + p.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.app"); + assertTrue(check(p).isEmpty()); } + /** + * And anything that is not a literal is left alone on both sides. + * + *

The value the build requests when the project names no container is + * "$(TeamIdentifierPrefix)$(CFBundleIdentifier)", two Xcode variables this has no business + * expanding, and a profile may grant a wildcard. Comparing either would warn about + * configurations that sign perfectly well, and a preflight that cries wolf is one people stop + * reading.

+ */ + @Test + public void aVariableOrWildcardContainerIsNotCompared() throws Exception { + Properties p = settings(profile("WithCloud", true)); + p.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "$(TeamIdentifierPrefix)$(CFBundleIdentifier)"); + + assertTrue("the Xcode-variable default was compared as a literal", check(p).isEmpty()); + } + /** * 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 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 index 13bbd1990b1..7e9db18dd24 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7790,6 +7790,47 @@ public void run() { + "happens to poll"); } + /** + * The simulator bridge hands out a real copy of what it advertised, nested containers too. + * + *

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

+ * + *

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

+ */ + @EdtTest + public void theSimulatorBridgeCopiesNestedPayloadContainers() { + RecordingProvider provider = new RecordingProvider(); + List lines = new ArrayList(); + lines.add("the original draft"); + provider.saved.put("lines", lines); + Continuity.setStateProvider(provider); + Continuity.routeStackChanged(); + Continuity.checkpoint(); + flushSerialCalls(); + + Map published = bridge.getPublishedInfo(); + assertNotNull(published, "nothing was advertised, so this test has nothing to copy"); + + // Reach into a NESTED container of what was handed back. + Object payload = published.get("payload"); + assertTrue(payload instanceof Map, "the advertised payload is not a map: " + payload); + Object got = ((Map) payload).get("lines"); + assertTrue(got instanceof List, "the nested list did not survive the round trip: " + got); + ((List) got).add("an edit the caller made to its own copy"); + + Map again = bridge.getPublishedInfo(); + Object second = ((Map) again.get("payload")).get("lines"); + assertEquals(1, ((List) second).size(), + "the caller's edit reached the advertised payload, so the bridge shares the " + + "container it says it copied: " + second); + } + /** * clear() empties the shelf, not only the slot. * From 6eeb9c83dab46ecb9270133c5fb353b685cfbd23 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:26:20 +0300 Subject: [PATCH 134/140] Read a newly installed relay once, not twice A regression from the commit before this one. Giving enable() the poll made setRelay()'s own poll redundant on the path where setRelay is what starts the session: the second call found `polling` already true, set pollAgain, and pollFinished() issued another GET. Every first installation read twice, and a relay whose document changed between the two handed the application two different snapshots and navigated the user twice for one setup call. enable() returns early when continuity is already on, so which of the two started the session decides who reads. Already running means enable() did nothing and this is the only thing that will ask a new endpoint; not running means enable() polled as the last thing it did. Both orders are tested, because inverting the condition would leave a newly installed relay unread -- a quieter failure than the double read and a worse one. The test wanted awaitFetched rather than awaitEntered, which gates on PUBLISH: with no state provider nothing ever publishes, so the first version hung for five seconds and reported a timeout. That read as the fix being broken and sent me into pollRelay's guards looking for a bug that was not there. A double that hangs is a worse diagnostic than one that fails, and the note is here because the next person to reach for GatedRelay will have the same choice to make. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 15 +++++- .../continuity/LocalContinuityTest.java | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 224bca64606..e82001a4189 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -657,8 +657,21 @@ public static void setRelay(StateRelay r) { endRelaySession(); relay = r; if (r != null) { + // Whether THIS call is the one that starts the session decides who does the reading. + // + // enable() returns early when continuity is already on, and it polls as the last + // thing it does when it is not -- so an unconditional poll here made the first + // installation read twice: the second call found `polling` already true, set + // pollAgain, and pollFinished() then issued a second GET. Two sequential reads for + // one setup, and if the relay's document changed between them the application was + // handed two different snapshots and navigated twice. + boolean wasEnabled = enabled; enable(); - pollRelay(); + if (wasEnabled) { + // Already running, so enable() did nothing at all. A new endpoint is a new source + // and nothing else is going to ask it. + pollRelay(); + } } } 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 index 7e9db18dd24..defdebfd5e4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7831,6 +7831,52 @@ public void theSimulatorBridgeCopiesNestedPayloadContainers() { + "container it says it copied: " + second); } + /** + * Installing a relay reads it ONCE, however the session happened to start. + * + *

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

+ * + *

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

+ */ + @EdtTest + public void installingARelayReadsItExactlyOnce() { + // setRelay() is what starts the session here: no provider has been set. + final GatedRelay first = new GatedRelay(); + Continuity.setRelay(first); + // awaitFetched, not awaitEntered: the gate is on PUBLISH, and no provider is set here so + // nothing ever publishes. Waiting on the wrong one hangs rather than fails. + awaitOffEdt(new Runnable() { + public void run() { + first.awaitFetched(1); + } + }); + // And then a moment longer, which is the whole point: a second GET queued behind the + // first arrives after it, so an assertion made the instant the first lands cannot see it. + pause(500L); + assertEquals(1, first.fetches(), + "installing a relay read it more than once: enable() polls when it starts the " + + "session, and the poll beside it queued a second GET behind the first"); + + // And the other order: already enabled, so enable() does nothing and this call has to be + // the one that asks the new endpoint. + final GatedRelay second = new GatedRelay(); + Continuity.setRelay(second); + awaitOffEdt(new Runnable() { + public void run() { + second.awaitFetched(1); + } + }); + pause(500L); + assertEquals(1, second.fetches(), + "a relay installed while continuity was already running was never read, so the " + + "new endpoint's state is not discovered until something else polls"); + } + /** * clear() empties the shelf, not only the slot. * From d54b7706c7b6a551db7f1eca5392a08bb4260998 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:10 +0300 Subject: [PATCH 135/140] End both drains when the session does, and stop a negative timestamp outliving maxAge The cold-launch drain handed every waiting state to the listeners without asking whether the first one had ended the session. dispatch() reaches application code, that code may call clear() -- a listener finding the account signed out is the documented shape -- and everything else in the drain arrived BEFORE that, so it was offered to the session that replaced it. dispatch() cannot notice on its own: it has no entry check for `enabled` and samples the generation afresh, so the next state looks like it belongs where it is actually crossing into. The sibling drain has had a guard since it was written and this loop was added later without one. Fixing that turned up the guard being wrong in the sibling too: it asked only whether continuity was still ENABLED, which sees a disable() and misses a clear() -- and clear() is the logout, the case that matters most, which deliberately leaves an enabled framework enabled. Both now ask the same question through stillTheSameSession(), which compares the generation as well. That helper also takes the generation as a PARAMETER rather than comparing the field against a local copy of itself. SpotBugs reads the latter as a self-comparison -- it cannot see that dispatch() reaches code that changes the field -- and reported SA_FIELD_SELF_COMPARISON on the first version. The inbound callback already had the accepted shape in decide(state, arrivedIn); this now matches it. A negative timestamp no longer outranks the expiry the application asked for. The codec refuses one in a relay DOCUMENT, but a custom StateRelay.fetch() returns an AppState directly and never goes through it, and neither does an application calling restore() with one it built. Every path ends in isTooOld(), so that is where the answer belongs. Worth recording how nearly the guard for it shipped untested. Disabling the explicit refusal left the test passing, because for an ordinary negative the subtraction already answers "too old" -- now minus a negative is enormous. The value it actually exists for is Long.MIN_VALUE, which OVERFLOWS that subtraction back to a negative, reads as fresh, and cannot be expired by any maxAge. The test covers both, and only the second one fails when the branch goes. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 76 ++++++++++++++-- .../continuity/LocalContinuityTest.java | 87 +++++++++++++++++++ 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e82001a4189..4d76d58b1cc 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -409,16 +409,45 @@ private static void drainPendingOffers() { AppState[] pending = takeAllPendingOffers(); int count = pending.length; // takeAllPendingOffers() has already put them oldest first. + int drainingIn = lifecycle; for (int a = 0; a < count; a++) { - if (!enabled) { - // A listener that ran during this drain is allowed to turn continuity off, and - // what follows the disable() must not then be admitted into it. + if (!stillTheSameSession(drainingIn)) { + // A listener that ran during this drain is allowed to end the session, and what + // follows must not then be admitted into the one that replaced it. + // + // This asked only whether continuity was still ENABLED, which sees a disable() and + // not a clear() -- and clear() is the logout, the case that matters most, which + // deliberately leaves an enabled framework enabled. So the generation is the thing + // to compare, and enabled is the other half of the same question. return; } admit(pending[a]); } } + /// Whether the session a drain started in is still the one running. + /// + /// Both halves, because either can end it: disable() clears `enabled` and clear() bumps the + /// generation while deliberately leaving it set. A drain hands each state to application code, + /// and that code is entitled to do either. + /// + /// The generation arrives as a PARAMETER rather than being compared against a local copy of + /// the field. That is the shape the inbound callback already uses -- decide(state, arrivedIn) + /// -- and it is also the one the analyzers accept: comparing a field to a local snapshot of + /// itself reads as a self-comparison to SpotBugs, which cannot see that dispatch() reaches + /// application code that may change it. + /// + /// #### Parameters + /// + /// - `generation`: the value of `lifecycle` when the drain began + /// + /// #### Returns + /// + /// true while the drain may go on + private static boolean stillTheSameSession(int generation) { + return enabled && lifecycle == generation; + } + /// Empties BOTH holders and returns what they held, oldest first. On the EDT. /// /// Snapshotted and cleared before the caller does anything with the result, because both @@ -1074,8 +1103,31 @@ public static AppState getRestorableState() { /// 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) { - return maxAge > 0 && state.getTimestamp() > 0 - && System.currentTimeMillis() - state.getTimestamp() > maxAge; + if (maxAge <= 0) { + // No expiry configured, so nothing expires. + return false; + } + long ts = state.getTimestamp(); + if (ts == 0) { + // The documented "this state carries no time". Nothing to measure against, so an + // application that built its own state by hand is not caught out by an age it never + // set a clock for. + return false; + } + if (ts < 0) { + // Malformed, and refused HERE rather than only at the wire. The codec rejects a + // negative ts in a relay DOCUMENT, but a custom StateRelay.fetch() returns an AppState + // directly and never goes through it -- and so does an application calling restore() + // with one it built. Every one of those paths ends up in this predicate, which is why + // the answer belongs here. + // + // Too old rather than exempt. Reading it as "carries no time" made a state that cannot + // prove its freshness immortal under exactly the maxAge the application configured to + // stop that -- an expired checkout restorable for the life of the install. A value + // that means nothing should not outrank an expiry that means something. + return true; + } + return System.currentTimeMillis() - ts > maxAge; } /// Restores whatever `getRestorableState()` offers. @@ -3136,7 +3188,21 @@ private static void windowWaitFinished(long deadline) { // how it came to be parked. Snapshotted and cleared before any of it is dispatched, // because a listener that defers lands straight back in placeOnOffer(). AppState[] pending = takeAllPendingOffers(); + // The session as it stands before any listener runs. dispatch() hands each state to + // application code, and that code is allowed to end the session -- a listener that finds + // the account signed out calls clear(), which is the documented shape. Everything after it + // in this array arrived BEFORE that happened, so continuing would offer the previous + // session's work to the one that replaced it, and dispatch() cannot notice on its own: it + // has no entry check for `enabled` and samples the generation afresh, so the next state + // looks like it belongs to the session it is actually crossing into. + // + // The sibling drain has had this since it was written -- "a listener that ran during this + // drain is allowed to turn continuity off" -- and this loop was added later without it. + int drainingIn = lifecycle; for (int i = 0; i < pending.length; i++) { + if (!stillTheSameSession(drainingIn)) { + return; + } dispatch(pending[i]); } // Whether they dispatched or were refused, neither holder is keeping anything back now. 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 index defdebfd5e4..2c2a228fc48 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7877,6 +7877,93 @@ public void run() { + "new endpoint's state is not discovered until something else polls"); } + /** + * A listener that ends the session during the cold-launch drain stops the rest of it. + * + *

dispatch() hands each state to application code, and that code may end the session -- a + * listener finding the account signed out calls clear(), which is the documented shape. + * Everything still in the drain arrived BEFORE that, so continuing offers the previous + * session's work to the one that replaced it. dispatch() cannot notice on its own: it has no + * entry check for enabled and samples the generation afresh, so the next state looks like it + * belongs to the session it is actually crossing into.

+ * + *

The pre-enable drain has had this guard since it was written; this loop was added later + * without it.

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

The codec refuses one in a relay DOCUMENT, but a custom StateRelay.fetch() returns an + * AppState directly and never goes through it, and so does an application calling restore() + * with a state it built. All of those reach isTooOld(), where a "positive timestamp" guard + * read a negative one as the documented "carries no time" and exempted it from every maxAge -- + * an expired checkout restorable for the life of the install, under exactly the setting that + * exists to stop it.

+ */ + @EdtTest + public void aNegativeTimestampIsNotExemptFromMaxAge() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Continuity.setMaxAge(60000L); + try { + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("a relay that returns nonsense")) + .setDeviceId("phone").setSequence(1L).setTimestamp(-1L)); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "a state whose timestamp cannot prove its freshness was offered anyway, so it " + + "is exempt from the maxAge that was configured to expire it"); + + // And the extreme, which is the value the explicit refusal is actually needed for. + // For an ordinary negative the subtraction already answers "too old" -- now minus a + // negative is enormous -- but Long.MIN_VALUE OVERFLOWS it back to a negative, so the + // comparison reads as fresh and the state becomes immortal by arithmetic rather than + // by the guard that used to exempt it. + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("the extreme of the same nonsense")) + .setDeviceId("tablet").setSequence(1L).setTimestamp(Long.MIN_VALUE)); + + AppState extreme = Continuity.getRestorableState(); + assertFalse(extreme != null && "tablet".equals(extreme.getDeviceId()), + "Long.MIN_VALUE overflowed the age subtraction back to a negative, so the " + + "state read as fresh and no maxAge can ever expire it"); + } finally { + Continuity.setMaxAge(0L); + } + } + /** * clear() empties the shelf, not only the slot. * From 094a52b425044ccbe5b87497e59f154f5c077757 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:04:18 +0300 Subject: [PATCH 136/140] Record why put() does not consult the sync entitlement A review asked for the entitlement to be ANDed into the synced-store write result, on the grounds that a build with ios.continuity.sync=false keeps a local store and so reports a write that can never leave the device. Both halves of that are true and the conclusion does not follow. An application deciding whether to offer the feature asks isSyncedStoreSupported(), and that already answers NO for an unentitled build -- it returns `resolved != nil && cn1ContinuitySyncEntitled` precisely so such a build cannot mistake itself for an entitled one. The fallback the review is worried about being skipped is selected by that call, not by put(). put() documents its answer as "true when the store holds the value afterwards", which is also the only thing establishable from inside it: whether iCloud goes on to propagate is not. And gating it on the entitlement is the same mistake that was removed from three layers at once -- IOSNative.m, IOSContinuityBridge and SyncedStore -- where it made every call unreachable and let a transient probe answer report failure for a value the store was holding and would have propagated. Putting it back in one of them restores a third of that bug. Comment only; no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index c5d9cc6c891..665185205c1 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20876,6 +20876,23 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_la // the store holds the value afterwards". It is also the only part that can be established // from in here: a store at its key or size limit drops the write while reporting nothing, and // whether iCloud goes on to propagate it is not knowable from inside this call. + // + // Nor is the ENTITLEMENT ANDed in here, which a review asked for on the grounds that a build + // with ios.continuity.sync=false keeps a local store and so reports a write that can never + // leave the device. Both halves of that are true and the conclusion does not follow. + // + // An application deciding whether to offer the feature asks isSyncedStoreSupported(), and that + // already answers NO for an unentitled build -- it returns `resolved != nil && + // cn1ContinuitySyncEntitled` precisely so a build without the entitlement cannot mistake + // itself for one that has it. The fallback the review is worried about is selected by that + // call, not by this one. + // + // And put() documents its answer as "true when the store holds the value afterwards", which + // is the only thing establishable from in here: whether iCloud goes on to propagate is not. + // Gating this on the entitlement is the same mistake that was removed from three layers at + // once -- IOSNative.m, IOSContinuityBridge and SyncedStore -- where it made every call + // unreachable and a transient probe answer report failure for a value the store was holding + // and would have propagated. Putting it back in one of them restores a third of that bug. NSString *back = [store stringForKey:k]; if (back != nil && [back isEqualToString:v]) { result = JAVA_TRUE; From 764cf9b04b6018f2796a2befc16fc05c982ffde4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:24:12 +0300 Subject: [PATCH 137/140] Give the off period a generation of its own clear() and disable() each advance the lifecycle generation and enable() did not, so the interval between a logout and the login after it carried the SAME generation as the session that followed. A continuation that reached the callback while continuity was off captured that generation and queued its decision; the decision ran after enable() had set the flag back, found the generation it captured still current, and admitted the previous account's work into the one that had just signed in. The callback had already answered "claimed", so the bridge was entitled to have dropped the only other copy. Advancing on enable is what makes the off period a session of its own: anything sampled during it is stale, which is what it is. BEFORE installCallback, and that ordering is the distinction rather than an accident. A continuation the port DECLINED earlier and is still holding is re-offered during that call, samples the generation as it now stands, and is admitted -- which is what enabling exists to pick up. One claimed and queued during the off period sampled the old one and is refused. Same mechanism, opposite answers, which is what the two cases deserve. The test needed staging that the obvious version did not provide, and the probe is what said so. Called from the test body the callback runs ON the event thread, takes its decision inline, and drops the arrival there -- the queued decision this is about never exists. invokeAndBlock does not help either: it keeps the event thread pumping, so the decision runs before the enable() rather than across it. A plain thread joined from the event thread is what holds it still and leaves the decision genuinely waiting, and only then does removing the bump restore the signed-out account's payload. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 20 ++++++ .../continuity/LocalContinuityTest.java | 69 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 4d76d58b1cc..e4aee50ba70 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -345,6 +345,26 @@ public static void enable() { recordDurable(e.getKey(), loaded); } } + // A NEW generation, because this is a new session and the off period had one of its own. + // + // clear() and disable() each advance it and enabling did not, so the interval between a + // logout and the login that follows carried the SAME generation as the session after it. + // An arrival that reached the callback while continuity was off captured that generation + // and queued its decision; the decision then ran after this method had set `enabled` back + // to true, found the generation it captured still current, and admitted the previous + // account's work into the one that just signed in. The callback had already answered + // "claimed", so the bridge was entitled to drop the only other copy. + // + // Advancing here is what makes the off period a session of its own: anything sampled + // during it is now stale, which is exactly what it is. + // + // BEFORE installCallback below, and that ordering is the whole of the distinction. A + // continuation the port DECLINED earlier and is still holding is re-offered during that + // call, samples the generation as it stands now, and is admitted -- which is what enabling + // is meant to pick up. One that was claimed and queued during the off period sampled the + // old one and is refused. Same mechanism, opposite answers, which is what the two cases + // deserve. + lifecycle++; enabled = true; applicationHasChosen = true; // Asking for what the port held: a continuation declined before this call is exactly what 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 index 2c2a228fc48..b3cb3f634fa 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -7964,6 +7964,75 @@ public void aNegativeTimestampIsNotExemptFromMaxAge() { } } + /** + * An arrival that landed while continuity was OFF does not join the session that follows. + * + *

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

+ * + *

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

+ */ + @EdtTest + public void anArrivalFromTheOffPeriodDoesNotJoinTheNextSession() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + flushSerialCalls(); + + // The documented logout. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + provider.restored = null; + + // A continuation lands while continuity is off. The callback claims it and queues the + // decision, which has NOT run yet. + final ContinuityCallback c = Continuity.callbackForTest(); + Map payload = new HashMap(); + payload.put("note", "the signed-out account's work"); + final Map wire = StateCodec.toMap(new AppState() + .setPayload(payload).setDeviceId("phone").setSequence(9L) + .setTimestamp(System.currentTimeMillis())); + final boolean[] claimed = new boolean[1]; + // A plain thread joined from here, NOT invokeAndBlock. The callback only queues its + // decision when it is called off the event thread -- on it, the decision is taken inline + // and this scenario cannot arise -- and invokeAndBlock keeps the event thread pumping, so + // the queued decision would run before the enable() below instead of across it. Joining + // holds the event thread still, which is what leaves the decision waiting. + Thread bridgeThread = new Thread(new Runnable() { + public void run() { + claimed[0] = c.continuationReceived(Continuity.getActivityType(), wire); + } + }, "bridge-off-edt"); + bridgeThread.start(); + try { + bridgeThread.join(4000L); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + assertTrue(claimed[0], + "the arrival was not claimed off-EDT, so nothing was queued and this test stages " + + "nothing"); + + // The login happens before the queued decision gets a turn. + Continuity.enable(); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "work that arrived while continuity was off was admitted into the session that " + + "followed, so the signed-out account's state reached the new one"); + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "the off-period arrival is on offer to the new session"); + } + /** * clear() empties the shelf, not only the slot. * From 4ba4e50212ac8f327ac71b440b7198de03b18e10 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:38:09 +0300 Subject: [PATCH 138/140] Recognize the brace spelling of an Xcode variable in the container check isLiteralContainer() excluded "$(NAME)" and not "${NAME}". Xcode accepts both, replaceBuildSetting() substitutes both on one line, and this project's own Mac entitlement test writes them in a single value -- "$(TeamIdentifierPrefix)${CFBundleIdentifier}". The comment beside that substitution records that handling one and not the other was already a bug here once. Missing a spelling does not fail to warn; it warns WRONGLY. The profile holds the expanded identifier, so an override Xcode would expand correctly was reported as a signing failure that will not happen -- which is the precise failure this check was written to avoid, since a preflight that cries wolf is one people stop reading. Two rounds ago I added the check with that reasoning in its comment and then left a spelling out of it. Now any dollar sign at all rather than a list of spellings. A container identifier is reverse-DNS and has no business containing one, so treating every such value as "not comparable" costs nothing and cannot miss a third form. Co-Authored-By: Claude Opus 5 (1M context) --- .../maven/IOSProvisioningPreflight.java | 12 +++++++++- .../maven/IOSContinuitySyncPreflightTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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 a46ad951ca7..97dc9dd9648 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java @@ -127,10 +127,20 @@ static class Profile { * *

An Xcode variable expands at build time and a wildcard covers a set, so neither is a * value this can hold against another one. Only a plain literal is.

+ * + *

Any {@code $} at all, not the {@code $(} spelling alone. Xcode accepts + * {@code ${CFBundleIdentifier}} equally, this project's own Mac entitlement test writes the + * two forms in a single value -- {@code $(TeamIdentifierPrefix)${CFBundleIdentifier}} -- and + * replaceBuildSetting() substitutes both, with a comment recording that handling one and not + * the other was already a bug once. Missing a spelling here does not fail to warn, it warns + * WRONGLY: the profile holds the expanded identifier, so an override Xcode would expand + * correctly gets reported as a signing failure that will not happen. A container identifier is + * reverse-DNS and has no business containing a dollar sign, so treating every one of them as + * "not comparable" costs nothing and cannot invent a third spelling to miss.

*/ private static boolean isLiteralContainer(String container) { return container != null && !container.isEmpty() - && container.indexOf("$(") < 0 && container.indexOf('*') < 0; + && container.indexOf('$') < 0 && container.indexOf('*') < 0; } /** A problem found before the build was sent: {@code message} is written for the user. */ 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 index b5e807f1a7c..03343d687ac 100644 --- 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 @@ -216,6 +216,30 @@ public void aVariableOrWildcardContainerIsNotCompared() throws Exception { assertTrue("the Xcode-variable default was compared as a literal", check(p).isEmpty()); } + /** + * The brace spelling is a variable too, and so is a value that mixes the two. + * + *

Xcode accepts ${CFBundleIdentifier} equally, replaceBuildSetting() substitutes both, and + * this project's own Mac entitlement test writes them in a single value. Recognising only + * "$(" does not fail to warn -- it warns WRONGLY, reporting a signing failure that will not + * happen for an override Xcode expands correctly.

+ */ + @Test + public void braceStyleAndMixedVariablesAreNotCompared() throws Exception { + Properties braces = settings(profile("WithCloudBraces", true)); + braces.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "${TeamIdentifierPrefix}${CFBundleIdentifier}"); + assertTrue("a ${...} override was compared as a literal", check(braces).isEmpty()); + + // A distinct name: the fixture writes a file named after the profile, so two in one test + // collide in the temporary folder. + Properties mixed = settings(profile("WithCloudMixed", true)); + mixed.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "$(TeamIdentifierPrefix)${CFBundleIdentifier}"); + assertTrue("a value mixing both spellings was compared as a literal", + check(mixed).isEmpty()); + } + /** * But a profile that grants NO key-value store at all is answerable, and naming a container * does not rescue it: the builder puts the entitlement in either way and codesigning rejects From 69198015fe135c7801a583ce33d4c9400334588c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:07:01 +0300 Subject: [PATCH 139/140] Stop holding the signed-out account's screen, and close the callback-install window clear() recorded the current form whether or not anything was restoring, and the only thing that ever reads it -- applicationChoseTheScreen() -- exists for a route factory or a show callback that ends the session from inside restoreStack(). So an ordinary logout left a static holding the signed-out account's whole component tree, and whatever the application hung off it, reachable for the rest of the next session. Recorded now only while applyingRestore is set, and dropped in a finally once the comparison has run. The sibling test -- a login form the logout callback chose surviving the undo -- is what says the comparison still has what it needs when a session really does end mid-restore. The iOS callback had a genuine interleaving, and this is the third thread-safety report on this file and the first that names one. The other two were about core fields whose stale reads fail SAFE: a stale generation drops an arrival the origin re-advertises. This one fails the other way. The native thread reads `callback` as null, the event thread installs one and finds pendingType still null, and only then does the native thread store it -- so the activity is left behind by the one installation that would have drained it. setCallback() runs at enable(), disable(), clear() and a bridge swap, so an application that does none of those again has LOST the Handoff rather than deferred it. Closed by re-reading the callback after storing the pending state, not by the synchronized handoff that was asked for. One of the two orders must hold: either the event thread reads pendingType after the write and drains it, or this re-read happens after the install and delivers it here. A lock would have to span the delivery to add anything over that, and spanning the delivery means calling into the framework -- and the event thread -- while holding it, which trades a lost activity for a deadlock. Not covered by a test, and that is worth stating plainly rather than leaving to be discovered: maven/ios has no test source root, so there is nowhere in this repository to exercise IOSContinuityCallbacks. The port builds, the native signatures resolve and SpotBugs is clean on the module, but none of that exercises the interleaving. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 35 +++++++++++++-- .../impl/ios/IOSContinuityCallbacks.java | 45 ++++++++++++++++--- .../continuity/LocalContinuityTest.java | 34 ++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index e4aee50ba70..1bc758f955f 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -263,6 +263,18 @@ public final class Continuity { /// show callback, so by the time restoreStack returns whatever the application did is already /// current and looks exactly like what the restore did. Sampling the display at the moment /// the session ended is the one point where the two are still distinguishable. + /// The screen that was showing when a session ended DURING a restore, or null. + /// + /// Recorded only while `applyingRestore` is set, and dropped as soon as the comparison that + /// wants it has run. Both halves are about what this field is: a strong reference to a Form, + /// which is a whole component tree and whatever the application hung off it. + /// + /// An ordinary logout used to fill it too -- clear() records the current screen whether or not + /// anything is restoring -- and nothing ever read it in that case, so the signed-out account's + /// entire UI stayed reachable through a static for the whole of the next session. The one + /// reader is applicationChoseTheScreen(), which exists for a route factory or a show callback + /// that ends the session from inside restoreStack(); outside that window there is no + /// comparison to make and no reason to hold the form. private static com.codename1.ui.Form formAtSessionEnd; /// Armed only while clear() drains whatever the port has been holding, and read by the @@ -590,8 +602,9 @@ public static void disable() { installCallback(true); return; } - // Sampled with the bump, not read later: see formAtSessionEnd. - formAtSessionEnd = Display.isInitialized() + // Sampled with the bump, not read later: see formAtSessionEnd. And only while a restore + // is in flight, which is the only thing that ever reads it -- see the gate's own comment. + formAtSessionEnd = applyingRestore && Display.isInitialized() ? Display.getInstance().getCurrent() : null; lifecycle++; enabled = false; @@ -1525,6 +1538,11 @@ private static boolean restore(final AppState state, boolean[] outFailed) { } } catch (Throwable t) { Log.e(t); + } finally { + // The comparison is over, so the form goes. Holding it any longer keeps the + // previous account's whole component tree reachable through a static for the rest + // of the process, and nothing is going to ask about it again. + formAtSessionEnd = null; } outFailed[0] = true; return false; @@ -1809,8 +1827,9 @@ public void run() { /// One thing it cannot undo: a relay request already on the wire when this is called. Nothing /// in this process can recall that. What this guarantees is that nothing follows it. public static void clear() { - // Sampled with the bump, not read later: see formAtSessionEnd. - formAtSessionEnd = Display.isInitialized() + // Sampled with the bump, not read later: see formAtSessionEnd. And only while a restore + // is in flight, which is the only thing that ever reads it -- see the gate's own comment. + formAtSessionEnd = applyingRestore && Display.isInitialized() ? Display.getInstance().getCurrent() : null; lifecycle++; parked = null; @@ -3512,6 +3531,14 @@ static ContinuityCallback callbackForTest() { return new Callback(); } + /// Test seam: the form a session-end recorded for the restore comparison, or null. + /// + /// The retention is the point of asking. This holds a whole component tree, and an ordinary + /// logout used to fill it for a comparison that only ever happens during a restore. + static com.codename1.ui.Form formAtSessionEndForTest() { + return formAtSessionEnd; + } + /// Test seam: parks a state, as a cold-launch arrival with no form yet does. static void parkForTest(AppState state) { placeOnOffer(state); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 9b881b5e7da..32ef8009524 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -153,13 +153,10 @@ public static boolean nativeContinuation(String activityType, String userInfoJso /// Hands an arrival to the framework, or holds it. Called on whatever thread the activity /// arrived on; the framework marshals what it needs to. private static boolean deliverToFramework(String activityType, String userInfoJson) { - // A plain read, and not a synchronized or volatile one. A review asked for safe - // publication here on the theory that this thread might still see null after the event - // thread installed the callback. Read what happens if it does: the arrival is RETAINED in - // pendingType/pendingJson below and false is returned, which is the same answer a decline - // gives -- and setCallback() drains those inline on every install. So the theoretical - // race costs a delivery deferred to the next install, not a lost activity, and the - // machinery to close it would be a lock on the path the OS calls for every Handoff. + // A plain read, and not a synchronized or volatile one. Two reviews have asked for safe + // publication here and the answer is the re-read at the bottom of this method rather than + // a lock -- see the comment there for why that closes the ordering, and why a lock would + // have to span a call into the event thread to add anything. ContinuityCallback c = callback; boolean claimed = false; if (c != null) { @@ -181,6 +178,40 @@ private static boolean deliverToFramework(String activityType, String userInfoJs // cold-launch continuation into a lost one. pendingType = activityType; pendingJson = userInfoJson; + // And then LOOK AGAIN, because the callback may have been installed since the read above. + // + // The interleaving is real and ordering, not visibility: this thread reads `callback` as + // null, the event thread installs one and finds pendingType still null, and only then does + // this thread store it. The activity is left behind by the one installation that would + // have drained it, and nothing else is coming -- setCallback() runs at enable(), disable(), + // clear() and a bridge swap, so an app that does none of those again has lost the Handoff + // rather than deferred it. + // + // Closed by re-reading rather than by locking. One of the two orders must hold: either the + // event thread reads pendingType after the write above and drains it, or this read happens + // after the install and delivers it here. A lock would have to span the delivery to add + // anything, and that means calling into the framework -- and the event thread -- while + // holding it, which trades a lost activity for a deadlock. + ContinuityCallback late = callback; + if (late == null || late == c) { //NOPMD CompareObjectsWithEquals + // Nothing arrived in the window, or the same one that already declined it. + return false; + } + boolean lateClaimed = false; + try { + lateClaimed = late.continuationReceived(activityType, parse(userInfoJson)); + } catch (Throwable t) { + Log.e(t); + } + if (lateClaimed) { + // Taken after all, so the port lets go -- but only of THIS activity. A newer one + // stored by another arrival in the meantime is not ours to clear. + if (activityType != null && activityType.equals(pendingType)) { + pendingType = null; + pendingJson = null; + } + return true; + } return false; } 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 index b3cb3f634fa..03f950e72cf 100644 --- a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -8033,6 +8033,40 @@ public void run() { "the off-period arrival is on offer to the new session"); } + /** + * An ordinary logout does not leave the account's screen held in a static. + * + *

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

+ * + *

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

+ */ + @EdtTest + public void anOrdinaryLogoutHoldsNoFormAfterwards() { + Continuity.setStateProvider(new RecordingProvider()); + new Form("the signed-out account's screen").show(); + flushSerialCalls(); + + Continuity.clear(); + + assertNull(Continuity.formAtSessionEndForTest(), + "the logout kept a reference to the account's screen, so its whole component " + + "tree stays reachable through a static for the next session"); + + // disable() records it on the same terms, and is the other half of the documented logout. + new Form("still nothing restoring").show(); + flushSerialCalls(); + Continuity.disable(); + + assertNull(Continuity.formAtSessionEndForTest(), + "disable() kept a reference to the screen outside any restore"); + } + /** * clear() empties the shelf, not only the slot. * From 36fece3b666229602572da23adb74655b8c8a4fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:33:27 +0300 Subject: [PATCH 140/140] Walk arrays and lists with foreach, and say why the callback fields stay plain Three loops replaced, because ForLoopCanBeForeach is on the project's forbidden PMD list and a plain walk of an array or a List is exactly what it is about. The build-test leg reported two of them and I fixed one of those plus one it had not reported, leaving the one it had -- reading the line numbers against the current file rather than inferring which code was meant would have caught that, and running the gate rather than eyeballing one report is what did. Worth recording how it got through: every check this session read spotbugsXml.xml and stopped there. The gate is generate-quality-report.py, which enforces SpotBugs, a forty-nine rule PMD list and Checkstyle together, and a clean SpotBugs report says nothing about the other two. It now exits 0 for both changed modules. The fourth review of IOSContinuityCallbacks asks, correctly, that the re-read added last commit establishes no happens-before edge: it settles the ORDERING between this thread and the event thread and not the visibility. Both remedies it offers are closed to this file. `volatile` is on the same forbidden list -- AvoidUsingVolatile -- and this port is one of the gated modules, which is the project deciding it does not pay for it; a lock would have to span the delivery to add anything over the re-read, and spanning the delivery means holding it while calling into the framework and the event thread. So the ordering is closed and the visibility is not, deliberately, and the field says so. What that leaves is a continuation delivered late rather than never: it stays in the pending pair, and setCallback() drains that inline at the next enable(), disable(), clear() or bridge swap. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/Continuity.java | 16 +++++------ .../impl/ios/IOSContinuityCallbacks.java | 28 ++++++++++++++----- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java index 1bc758f955f..339f25135f1 100644 --- a/CodenameOne/src/com/codename1/continuity/Continuity.java +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -439,10 +439,11 @@ private static void drainPendingOffers() { return; } AppState[] pending = takeAllPendingOffers(); - int count = pending.length; - // takeAllPendingOffers() has already put them oldest first. + // takeAllPendingOffers() has already put them oldest first. Foreach rather than an index: + // PMD's ForLoopCanBeForeach is on the project's forbidden list, and a plain walk of an + // array is exactly what it is about. int drainingIn = lifecycle; - for (int a = 0; a < count; a++) { + for (AppState state : pending) { if (!stillTheSameSession(drainingIn)) { // A listener that ran during this drain is allowed to end the session, and what // follows must not then be admitted into the one that replaced it. @@ -453,7 +454,7 @@ private static void drainPendingOffers() { // to compare, and enabled is the other half of the same question. return; } - admit(pending[a]); + admit(state); } } @@ -2012,8 +2013,7 @@ private static void endRelaySession() { /// restore's own order with only gaps in it is the restore's own. private static boolean isStillTheRestoredStack(List live, List requested) { int at = 0; - for (int i = 0; i < live.size(); i++) { - String path = live.get(i); + for (String path : live) { while (at < requested.size() && !requested.get(at).equals(path)) { at++; } @@ -3238,11 +3238,11 @@ private static void windowWaitFinished(long deadline) { // The sibling drain has had this since it was written -- "a listener that ran during this // drain is allowed to turn continuity off" -- and this loop was added later without it. int drainingIn = lifecycle; - for (int i = 0; i < pending.length; i++) { + for (AppState state : pending) { if (!stillTheSameSession(drainingIn)) { return; } - dispatch(pending[i]); + dispatch(state); } // Whether they dispatched or were refused, neither holder is keeping anything back now. startPublisher(); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java index 32ef8009524..3a6a6a4daeb 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -45,13 +45,27 @@ final class IOSContinuityCallbacks { /// The framework's inbound seam, owned by the event thread. /// - /// The platform hands a continuation over on a thread of its own, so `nativeContinuation` - /// marshals with `com.codename1.ui.Display#callSerially` and everything below it is ordinary - /// EDT code. The one arrival that cannot be marshalled is the one that beats the event thread - /// into existence -- a cold launch delivers from `willConnectToSession`, before Display is - /// initialized -- and that one is parked on the platform's thread. It needs no guard either: - /// the writes happen before the EDT is started, and starting a thread publishes everything - /// written before it. + /// Read and written from the platform's thread as well as the event thread, and PLAIN -- + /// neither volatile nor guarded, because this project does not allow either here. + /// + /// The doc here used to say the platform's arrival is marshalled and everything below is + /// ordinary EDT code. That stopped being true when nativeContinuation() began calling + /// deliverToFramework() inline: the answer is owed to the OS synchronously and the framework + /// binds an arrival to the generation it arrived in, so the hand-over happens on whatever + /// thread the activity came in on, while the event thread may be installing a callback. + /// + /// Four reviews have now asked for safe publication of these fields, the last of them + /// observing correctly that the re-read in deliverToFramework() settles the ORDERING and + /// establishes no happens-before edge. Both remedies it offers are closed to this file: + /// `volatile` is on the project's forbidden PMD list (AvoidUsingVolatile) and this port is + /// one of the gated modules, and a lock would have to span the delivery to add anything -- + /// which means holding it while calling into the framework and the event thread, trading a + /// missed activity for a deadlock. + /// + /// So the ordering is closed and the visibility is not, deliberately. What that leaves is a + /// continuation delivered late rather than never: the arrival stays in the pending pair + /// below, and setCallback() drains it inline at the next enable(), disable(), clear() or + /// bridge swap. private static ContinuityCallback callback; /// Written once by the class initializer, which every thread's first touch of this class