From f1f2e71f2f02fd0d2eadabaca4fe64a0ce404c30 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:41:50 +0300 Subject: [PATCH 01/25] State restoration and continuity across devices A Codename One app that the operating system kills comes back to its first screen. Lifecycle.stop() kept the current Form in a plain field, so a suspend and resume looked right and a reclaimed process lost everything -- which on Android is the ordinary outcome of a few minutes in another app. com.codename1.continuity saves what the user was doing and brings it back, and on Apple platforms offers that same work to the other devices the person is signed in to. The substrate was already here and unused: com.codename1.router keeps a stack of deep-link paths, which is exactly a serializable, portable "where the user is", and Navigation.restoreStack rebuilds it without animating through every screen on the way. Two packages, because they cost different things. com.codename1.continuity buys a native define and one NSUserActivityTypes entry and no entitlement; com.codename1.continuity.sync buys the iCloud key-value store, whose entitlement has to be granted on the App ID. Handing that to an app that only wanted to pass work to the tablet in the user's other hand would fail its codesigning for a capability it never asked for -- the same split, for the same reason, as usesSmartHome and usesHomeAccessoryData. Three decisions worth recording: - Saving is continuous, not at shutdown. Every navigation schedules a checkpoint that is written once per event loop pass. Android's generated activity blocks the platform main thread until the app's stop() returns, so an app that saved there would pay for it on every suspend. - Nothing happens until the application opts in. start() is unchanged for every existing app, and restore() is never called for anyone -- where restoration belongs in a launch is a decision only the app can make. - Codename One runs no relay server. Continuation between Apple devices is the platform's; everything else goes through a StateRelay against the app's own endpoint, because deciding which saved states belong to the same person is the app's account system's question. The iOS delegate matches continuity BEFORE intents, and the order is load-bearing: the intents block ends in a general branch that hands any remaining activity to Java and returns Java's answer, and Intents.dispatchUserActivity correctly declines a type it never declared. An app using both would have had its own continuation asked about by the wrong framework, told no, and dropped. NSUserActivityTypes stays a single key for the same reason a second one is worse than none: iOS reads a duplicated key unpredictably, so the two contributors meet in userActivityTypesKey. Android needs nothing injected -- no permission, no manifest entry, no dependency -- and the bridge exists there for one job: flushing the checkpoint from onSaveInstanceState, the last callback guaranteed before a background process is reclaimed. Both cross-device capabilities report themselves unsupported rather than being emulated, because an app told "yes" by a bridge that then dropped the state is worse off than one told "no", which can fall back to a relay and reach an iPhone as easily as another Android. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/continuity/AppState.java | 333 ++++++ .../com/codename1/continuity/Continuity.java | 961 ++++++++++++++++++ .../continuity/ContinuityListener.java | 52 + .../codename1/continuity/RestStateRelay.java | 144 +++ .../com/codename1/continuity/StateCodec.java | 272 +++++ .../codename1/continuity/StateProvider.java | 60 ++ .../com/codename1/continuity/StateRelay.java | 63 ++ .../codename1/continuity/package-info.java | 37 + .../continuity/spi/ContinuityBridge.java | 101 ++ .../continuity/spi/ContinuityCallback.java | 52 + .../continuity/spi/package-info.java | 27 + .../continuity/sync/SyncedStore.java | 259 +++++ .../continuity/sync/SyncedStoreListener.java | 33 + .../continuity/sync/package-info.java | 30 + .../impl/CodenameOneImplementation.java | 14 + .../continuity/LocalContinuityBridge.java | 233 +++++ .../impl/continuity/package-info.java | 26 + .../src/com/codename1/router/Navigation.java | 71 ++ CodenameOne/src/com/codename1/ui/Display.java | 12 + .../impl/android/AndroidImplementation.java | 20 + .../continuity/AndroidContinuityBridge.java | 156 +++ .../codenameone/simulator-hooks.properties | 50 +- .../impl/javase/ContinuitySimulatorHooks.java | 191 ++++ .../com/codename1/impl/javase/JavaSEPort.java | 51 + .../nativeSources/CodenameOne_GLAppDelegate.m | 68 +- .../CodenameOne_GLViewController.h | 14 + Ports/iOSPort/nativeSources/IOSNative.m | 297 ++++++ .../impl/ios/IOSContinuityBridge.java | 181 ++++ .../impl/ios/IOSContinuityCallbacks.java | 145 +++ .../codename1/impl/ios/IOSImplementation.java | 15 + .../src/com/codename1/impl/ios/IOSNative.java | 39 + .../ContinuitySample/ContinuitySample.java | 228 +++++ .../codenameone_settings.properties | 9 + .../continuity/ContinuitySnippets.java | 175 ++++ ...tate-restoration-and-continuity.properties | 9 + .../State-Restoration-And-Continuity.asciidoc | 306 ++++++ docs/developer-guide/developer-guide.asciidoc | 2 + docs/website/data/port_status.json | 9 + .../codename1/build/shared/BuildHintsIos.java | 24 + .../com/codename1/builders/IPhoneBuilder.java | 121 ++- .../com/codename1/maven/CN1BuildMojo.java | 1 + .../maven/IOSProvisioningPreflight.java | 82 ++ .../IPhoneBuilderContinuityPlistTest.java | 195 ++++ .../maven/IOSContinuitySyncPreflightTest.java | 177 ++++ .../continuity/AppStateWireTest.java | 266 +++++ .../continuity/ContinuityDegradationTest.java | 202 ++++ .../continuity/LocalContinuityTest.java | 504 +++++++++ .../continuity/RouteStackRestoreTest.java | 236 +++++ .../simulator/ShippedSimulatorHooksTest.java | 179 ++++ .../tests/Cn1ssDeviceRunner.java | 6 + .../tests/ContinuityStateTest.java | 189 ++++ .../resources/skill/references/build-hints.md | 25 + 52 files changed, 6941 insertions(+), 11 deletions(-) create mode 100644 CodenameOne/src/com/codename1/continuity/AppState.java create mode 100644 CodenameOne/src/com/codename1/continuity/Continuity.java create mode 100644 CodenameOne/src/com/codename1/continuity/ContinuityListener.java create mode 100644 CodenameOne/src/com/codename1/continuity/RestStateRelay.java create mode 100644 CodenameOne/src/com/codename1/continuity/StateCodec.java create mode 100644 CodenameOne/src/com/codename1/continuity/StateProvider.java create mode 100644 CodenameOne/src/com/codename1/continuity/StateRelay.java create mode 100644 CodenameOne/src/com/codename1/continuity/package-info.java create mode 100644 CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java create mode 100644 CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java create mode 100644 CodenameOne/src/com/codename1/continuity/spi/package-info.java create mode 100644 CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java create mode 100644 CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java create mode 100644 CodenameOne/src/com/codename1/continuity/sync/package-info.java create mode 100644 CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java create mode 100644 CodenameOne/src/com/codename1/impl/continuity/package-info.java create mode 100644 Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java create mode 100644 Samples/samples/ContinuitySample/ContinuitySample.java create mode 100644 Samples/samples/ContinuitySample/codenameone_settings.properties create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java create mode 100644 docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties create mode 100644 docs/developer-guide/State-Restoration-And-Continuity.asciidoc create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/ContinuityDegradationTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/simulator/ShippedSimulatorHooksTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java new file mode 100644 index 00000000000..a9d280aff8b --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.io.Externalizable; +import com.codename1.io.Util; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your +/// `StateProvider` chose to add. +/// +/// The same value serves three purposes, which is why it carries more than the two halves above. +/// It is written to storage so the app can come back after its process dies; it is advertised to +/// the user's other devices so one of them can continue the work; and it travels through a +/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and +/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo +/// -- from one worth acting on. +/// +/// #### The routes +/// +/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first. +/// Restoring it re-runs each path through the route table, which is why an app that navigates with +/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those +/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores +/// from the payload instead. +/// +/// #### The payload +/// +/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system +/// and delivered to a *different device running a possibly different build of your app*, so it is +/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`, +/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built, +/// with a message naming the offending key, rather than being dropped somewhere the failure cannot +/// be traced back here. +public final class AppState implements Externalizable { + /// The `Util.register` id. Changing it orphans every state already on a device. + static final String OBJECT_ID = "CN1AppState"; + + private List routes = new ArrayList(); + private Map payload = new HashMap(); + private String deviceId = ""; + private String title; + private long sequence; + private long timestamp; + + /// Creates an empty state. Applications normally obtain one from + /// `Continuity.getRestorableState()` or receive one through a `ContinuityListener`; this is + /// public so tests and relays can build one. + public AppState() { + } + + /// The navigation stack as route paths, oldest first. Never null, possibly empty. + /// + /// #### Returns + /// + /// an unmodifiable view of the route paths + public List getRoutes() { + return Collections.unmodifiableList(routes); + } + + /// Replaces the route paths. + /// + /// #### Parameters + /// + /// - `r`: the paths, oldest first; null is treated as empty + /// + /// #### Returns + /// + /// this state, for chaining + public AppState setRoutes(List r) { + routes = new ArrayList(); + if (r != null) { + for (Iterator i = r.iterator(); i.hasNext();) { + String path = i.next(); + if (path != null && path.length() > 0) { + routes.add(path); + } + } + } + return this; + } + + /// The application payload. Never null, possibly empty. + /// + /// #### Returns + /// + /// an unmodifiable view of the payload + public Map getPayload() { + return Collections.unmodifiableMap(payload); + } + + /// Replaces the application payload. + /// + /// #### Parameters + /// + /// - `p`: the payload; null is treated as empty + /// + /// #### Returns + /// + /// this state, for chaining + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when a value cannot cross to another device + public AppState setPayload(Map p) { + StateCodec.requireRepresentable(p); + payload = new HashMap(); + if (p != null) { + payload.putAll(p); + } + return this; + } + + /// Replaces the payload without validating it. Used only for a payload that arrived from + /// another device: it was already validated where it was produced, and refusing it here would + /// turn a remote mistake into an exception on this device at a moment the user cannot connect + /// to anything they did. + /// + /// #### Parameters + /// + /// - `p`: the payload; null is treated as empty + void setPayloadUnchecked(Map p) { + payload = new HashMap(); + if (p != null) { + payload.putAll(p); + } + } + + /// The device this state was produced on. Used to drop a state's own echo when it comes back + /// through a relay. Never null. + /// + /// #### Returns + /// + /// the originating device id + public String getDeviceId() { + return deviceId; + } + + /// Sets the originating device id. + /// + /// #### Parameters + /// + /// - `id`: the id; null is treated as the empty string + /// + /// #### Returns + /// + /// this state, for chaining + public AppState setDeviceId(String id) { + deviceId = id == null ? "" : id; + return this; + } + + /// A human readable label for what the user is doing, which a receiving device may show + /// before they accept the continuation. Null when the app did not set one. + /// + /// #### Returns + /// + /// the title, or null + public String getTitle() { + return title; + } + + /// Sets the human readable label. + /// + /// #### Parameters + /// + /// - `t`: the title, or null for none + /// + /// #### Returns + /// + /// this state, for chaining + public AppState setTitle(String t) { + title = t; + return this; + } + + /// A counter that increases with every state this device publishes. Together with the device + /// id it identifies a state exactly, which is how a receiver recognizes one it has already + /// acted on -- two states can share a timestamp, because clocks are coarse. + /// + /// #### Returns + /// + /// the sequence number + public long getSequence() { + return sequence; + } + + /// Sets the sequence number. + /// + /// #### Parameters + /// + /// - `s`: the sequence number + /// + /// #### Returns + /// + /// this state, for chaining + public AppState setSequence(long s) { + sequence = s; + return this; + } + + /// When this state was produced, as milliseconds since the epoch on the producing device. + /// + /// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as + /// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual + /// correction -- both within one session. + /// + /// #### Returns + /// + /// the timestamp + public long getTimestamp() { + return timestamp; + } + + /// Sets the production timestamp. + /// + /// #### Parameters + /// + /// - `t`: milliseconds since the epoch + /// + /// #### Returns + /// + /// this state, for chaining + public AppState setTimestamp(long t) { + timestamp = t; + return this; + } + + /// True when there is nothing here worth restoring or sending. + /// + /// #### Returns + /// + /// true when both the routes and the payload are empty + public boolean isEmpty() { + return routes.isEmpty() && payload.isEmpty(); + } + + @Override + public String toString() { + return "AppState{routes=" + routes.size() + ", payload=" + payload.size() + + ", device=" + deviceId + ", seq=" + sequence + "}"; + } + + // ------------------------------------------------------------------ + // Externalizable -- the on-device format + // ------------------------------------------------------------------ + + @Override + public int getVersion() { + return 1; + } + + @Override + public String getObjectId() { + return OBJECT_ID; + } + + @Override + public void externalize(DataOutputStream out) throws IOException { + Util.writeUTF(deviceId, out); + Util.writeUTF(title, out); + out.writeLong(sequence); + out.writeLong(timestamp); + out.writeInt(routes.size()); + for (Iterator i = routes.iterator(); i.hasNext();) { + Util.writeUTF(i.next(), out); + } + // The payload goes through the framework's own object writer rather than a hand-rolled + // encoding: it already knows every type requireRepresentable admits, including nested + // lists and maps, and it is the same writer Storage uses for everything else. + Util.writeObject(payload, out); + } + + @Override + public void internalize(int version, DataInputStream in) throws IOException { + deviceId = Util.readUTF(in); + if (deviceId == null) { + deviceId = ""; + } + title = Util.readUTF(in); + sequence = in.readLong(); + timestamp = in.readLong(); + int count = in.readInt(); + routes = new ArrayList(); + for (int i = 0; i < count; i++) { + String path = Util.readUTF(in); + if (path != null && path.length() > 0) { + routes.add(path); + } + } + Object p = Util.readObject(in); + payload = new HashMap(); + if (p instanceof Map) { + Map read = (Map) p; + for (Iterator> 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 3db0273961c..dc18c153587 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6450,6 +6450,20 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return null; } + /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the + /// user's current activity to their other devices and to reach the platform's synced key/value + /// store. Ports supporting either capability override this; the base implementation returns + /// null, which leaves saving and restoring state on this device working -- that half is pure + /// `com.codename1.io.Storage` -- and makes every cross-device capability report itself + /// unsupported. + /// + /// #### Returns + /// + /// the continuity bridge, or null when unsupported + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return null; + } + /// Returns the platform bridge used by the `com.codename1.intents` API to expose the /// application's capabilities to the system -- assistant intents, app shortcuts and device /// search. Ports supporting intents override this; the base implementation returns null, which diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java new file mode 100644 index 00000000000..c1c294f9497 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.continuity; + +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.Log; +import com.codename1.io.Preferences; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// A simulated continuity platform, used by the simulator, the desktop builds and the unit tests. +/// +/// A simulation rather than nothing, for the reason the call and nearby bridges carry one: almost +/// everything an app does with continuity -- deciding what belongs in the payload, prompting before +/// a jump, rebuilding a screen from a route -- has nothing to do with the operating system that +/// carries the state, and a port that reported nothing would make all of it testable only on a +/// pair of phones. +/// +/// It keeps the last published activity in memory so the Simulate menu can show what the app is +/// offering, and it can hand that activity straight back through `simulateArrival()` -- which is +/// what "continue this on another device" is, minus the second device. The synced store is real +/// within one machine: it is backed by `com.codename1.io.Preferences`, so it survives a simulator +/// restart the way the platform store survives a device one. +public class LocalContinuityBridge implements ContinuityBridge { + /// Prefix for the simulated synced store's keys inside `Preferences`. + private static final String PREFIX = "CN1$SyncedStore$"; + + /// The list of keys, kept beside them because `Preferences` cannot be enumerated. + private static final String INDEX = "CN1$SyncedStoreKeys"; + + private ContinuityCallback callback; + private String publishedType; + private String publishedTitle; + private Map publishedInfo; + + public void setCallback(ContinuityCallback c) { + callback = c; + } + + public boolean isContinuationSupported() { + return true; + } + + public void publishContinuation(String activityType, String title, + Map userInfo) { + publishedType = activityType; + publishedTitle = title; + publishedInfo = userInfo == null ? null : new HashMap(userInfo); + } + + public void clearContinuation() { + publishedType = null; + publishedTitle = null; + publishedInfo = null; + } + + /// The activity type currently advertised, or null when nothing is. + /// + /// #### Returns + /// + /// the type + public String getPublishedType() { + return publishedType; + } + + /// The label currently advertised, or null. + /// + /// #### Returns + /// + /// the label + public String getPublishedTitle() { + return publishedTitle; + } + + /// The payload currently advertised, or null when nothing is. + /// + /// #### Returns + /// + /// a copy of the payload + public Map getPublishedInfo() { + return publishedInfo == null ? null : new HashMap(publishedInfo); + } + + /// Delivers the currently advertised activity back to the app as though it had arrived from + /// another device, which is what the Simulate menu's "continue on this device" does. + /// + /// The device id inside the payload is rewritten first. Without that the framework would + /// recognize the state as this device's own echo and correctly ignore it, and the menu item + /// would appear to do nothing. + /// + /// #### Returns + /// + /// true when there was an activity to deliver and the app claimed it + public boolean simulateArrival() { + if (publishedType == null || publishedInfo == null) { + return false; + } + Map copy = new HashMap(publishedInfo); + copy.put("device", "simulated-device"); + return simulateArrival(publishedType, copy); + } + + /// Delivers an arbitrary activity, for tests that build their own. + /// + /// #### Parameters + /// + /// - `activityType`: the type it arrives under + /// - `userInfo`: the payload + /// + /// #### Returns + /// + /// true when the app claimed it + public boolean simulateArrival(String activityType, Map userInfo) { + ContinuityCallback c = callback; + if (c == null) { + return false; + } + try { + return c.continuationReceived(activityType, userInfo); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + // ------------------------------------------------------------------ + // Synced store + // ------------------------------------------------------------------ + + public boolean isSyncedStoreSupported() { + return true; + } + + public void syncedStorePut(String key, String value) { + Preferences.set(PREFIX + key, value); + List keys = indexKeys(); + if (!keys.contains(key)) { + keys.add(key); + writeIndex(keys); + } + } + + public String syncedStoreGet(String key) { + return Preferences.get(PREFIX + key, null); + } + + public void syncedStoreRemove(String key) { + Preferences.delete(PREFIX + key); + List keys = indexKeys(); + if (keys.remove(key)) { + writeIndex(keys); + } + } + + public String[] syncedStoreKeys() { + List keys = indexKeys(); + return keys.toArray(new String[keys.size()]); + } + + /// Reports a change made "on another device", which the Simulate menu uses to exercise an + /// app's `SyncedStoreListener` without a second machine. + public void simulateStoreChange() { + ContinuityCallback c = callback; + if (c == null) { + return; + } + try { + c.syncedStoreChanged(); + } catch (Throwable t) { + Log.e(t); + } + } + + private List indexKeys() { + List keys = new ArrayList(); + String raw = Preferences.get(INDEX, ""); + if (raw == null || raw.length() == 0) { + return keys; + } + // Newline separated because a synced store key is an application-chosen string and the + // separators one might reach for first -- comma, semicolon, space -- are all plausible + // inside one. A newline is not, and put() is the only writer. + int start = 0; + while (start <= raw.length()) { + int end = raw.indexOf('\n', start); + if (end < 0) { + end = raw.length(); + } + String key = raw.substring(start, end); + if (key.length() > 0 && !keys.contains(key)) { + keys.add(key); + } + start = end + 1; + } + return keys; + } + + private void writeIndex(List keys) { + StringBuilder sb = new StringBuilder(); + for (Iterator i = keys.iterator(); i.hasNext();) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(i.next()); + } + Preferences.set(INDEX, sb.toString()); + } +} diff --git a/CodenameOne/src/com/codename1/impl/continuity/package-info.java b/CodenameOne/src/com/codename1/impl/continuity/package-info.java new file mode 100644 index 00000000000..d77c8207f77 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/continuity/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The simulated continuity platform behind the simulator, the desktop builds and the unit tests. +/// +/// Internal. Application code uses `com.codename1.continuity`. +package com.codename1.impl.continuity; diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 0a582da09ef..3302311dfac 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -105,6 +105,7 @@ public static boolean navigate(String path) { } stack.add(new NavigationEntry(path, f)); f.show(); + stackChanged(); return true; } @@ -119,6 +120,7 @@ public static boolean back() { stack.remove(stack.size() - 1); NavigationEntry now = stack.get(stack.size() - 1); now.getForm().showBack(); + stackChanged(); return true; } @@ -162,9 +164,78 @@ public static boolean popTo(NavigationEntry entry) { stack.remove(stack.size() - 1); } entry.getForm().showBack(); + stackChanged(); return true; } + /// Rebuilds the stack from a list of paths, showing only the last one. + /// + /// This is how `com.codename1.continuity.Continuity` puts the user back where they were: the + /// saved state is a list of paths, and every one of them has to become a stack frame or + /// `back()` would land on a screen that was never built. Replaying them with `navigate` would + /// work and would also flash every intermediate screen past the user with a transition each, + /// so the frames are built silently and only the top one is shown. + /// + /// Paths that no longer match a route are skipped rather than failing the restore. A rebuilt + /// app legitimately drops routes, and refusing to restore anything because one deep frame went + /// away would lose the whole session over a screen the user was not on. + /// + /// Replaces whatever was on the stack. Must be called on the EDT. + /// + /// #### Parameters + /// + /// - `paths`: the paths, oldest first + /// + /// #### Returns + /// + /// true when at least one frame was rebuilt and shown + public static boolean restoreStack(List paths) { + RouteDispatcher d = dispatcher; + if (d == null || paths == null || paths.isEmpty()) { + return false; + } + List rebuilt = new ArrayList(); + for (int i = 0; i < paths.size(); i++) { + String path = paths.get(i); + if (path == null || path.length() == 0) { + continue; + } + Form f; + try { + f = d.dispatch(path); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + continue; + } + if (f != null) { + rebuilt.add(new NavigationEntry(path, f)); + } + } + if (rebuilt.isEmpty()) { + return false; + } + stack.clear(); + stack.addAll(rebuilt); + // show(), not showBack(): the user is arriving, not going back, and showBack would run + // the reverse transition into a screen they have not seen yet. + rebuilt.get(rebuilt.size() - 1).getForm().show(); + stackChanged(); + return true; + } + + /// Tells the continuity framework that the stack moved, so it can checkpoint. + /// + /// A direct call rather than a listener: `Continuity.routeStackChanged()` returns immediately + /// unless an application actually enabled continuity, and a listener registry here would be + /// public API earned by one internal caller. + private static void stackChanged() { + try { + com.codename1.continuity.Continuity.routeStackChanged(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + // ------------------------------------------------------------------------ // Internal: framework-side entry point invoked by Display when the // platform delivers a deep link through `AppArg`. diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 59fdef98def..3fc8602f960 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -5873,6 +5873,18 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return impl.getDocumentProviderBridge(); } + /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the + /// user's current activity to their other devices and to reach the platform's synced key/value + /// store, or null when unsupported on this port. Internal -- application code uses the + /// `com.codename1.continuity` API rather than this bridge directly. + /// + /// #### Returns + /// + /// the continuity bridge, or null + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return impl.getContinuityBridge(); + } + /// Returns the platform bridge used by the `com.codename1.intents` API to expose the /// application's capabilities to the system, or null when unsupported on this port. Internal -- /// application code uses the `com.codename1.intents` API rather than this bridge directly. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 21660be5870..082f97e400f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -6485,6 +6485,26 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return documentProviderBridge; } + private com.codename1.continuity.spi.ContinuityBridge continuityBridge; + + /// Returns the continuity bridge, which on Android exists for one job: + /// flushing the state checkpoint when the platform says the process may + /// be killed. Neither cross-device capability exists here and both report + /// themselves unsupported. + /// + /// Synchronized for the reason the intent bridge is: two callers arriving + /// together would each construct one, and each construction registers a + /// lifecycle listener -- so the loser's listener would stay registered and + /// the app would checkpoint twice on every save. + @Override + public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + if (continuityBridge == null) { + continuityBridge = + new com.codename1.impl.android.continuity.AndroidContinuityBridge(); + } + return continuityBridge; + } + private com.codename1.intents.spi.IntentBridge intentBridge; @Override diff --git a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java new file mode 100644 index 00000000000..6a54d9cdd84 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.continuity; + +import android.os.Bundle; + +import com.codename1.continuity.Continuity; +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.impl.android.LifecycleListener; +import com.codename1.io.Log; + +import java.util.Map; + +/// Android's half of the continuity framework, which is smaller than the Apple one because the +/// platform offers less. +/// +/// #### What Android has +/// +/// Saving and restoring on this device, which is the part that matters most here: Android reclaims +/// a backgrounded process routinely, far more readily than iOS does, so an app without this comes +/// back to its first screen after nothing more than a few minutes in another app. That half is +/// pure `com.codename1.io.Storage` and needs no bridge at all; what this class adds is a flush at +/// the one moment the platform tells the app it is about to be killed. +/// +/// #### What Android does not have +/// +/// There is no system service that advertises what the user is doing to the other devices they +/// own, and no key/value store the platform syncs between them. Both are reported unsupported +/// rather than emulated: an app told "yes" by a bridge that then dropped the state would be worse +/// off than one told "no", which can fall back to a `com.codename1.continuity.StateRelay` and +/// reach an iPhone as easily as another Android. +/// +/// This is the honest shape of the platform difference, and it is why the developer guide's +/// capability table has a column per platform rather than a single "supported" claim. +public class AndroidContinuityBridge implements ContinuityBridge { + + /// Registers the flush hook. Called once, when the port builds the bridge. + public AndroidContinuityBridge() { + try { + AndroidNativeUtil.addLifecycleListener(new FlushOnSave()); + } catch (Throwable t) { + Log.e(t); + } + } + + public void setCallback(ContinuityCallback callback) { + // Nothing to deliver: neither capability below exists on this platform, so the framework's + // inbound seam is never reached from here. States still arrive on Android -- through a + // StateRelay, which the framework drives itself and which needs no port support. + } + + public boolean isContinuationSupported() { + return false; + } + + public void publishContinuation(String activityType, String title, + Map userInfo) { + } + + public void clearContinuation() { + } + + public boolean isSyncedStoreSupported() { + return false; + } + + public void syncedStorePut(String key, String value) { + } + + public String syncedStoreGet(String key) { + return null; + } + + public void syncedStoreRemove(String key) { + } + + public String[] syncedStoreKeys() { + return new String[0]; + } + + /// Flushes the checkpoint when the platform says the process may be killed. + /// + /// `onSaveInstanceState` is the right hook and `onStop` is not. Android calls this one *before* + /// stopping, while the app is still whole, and it is the last callback guaranteed to run + /// before a background process is reclaimed. The app's own `stop()` is not: the generated + /// activity blocks Android's main thread waiting for it, so work added there is paid for on + /// every ordinary suspend. + /// + /// The framework has almost always written the state already -- it checkpoints as the user + /// navigates rather than at shutdown -- so this exists for the payload edited after the last + /// navigation, and is a no-op the rest of the time. + private static final class FlushOnSave implements LifecycleListener { + @Override + public void onCreate(Bundle savedInstanceState) { + } + + @Override + public void onResume() { + // A relay is the only channel Android has, and nothing reads it on its own. Asking + // here is what makes "picked it up on the iPad, opened the phone" work: the poll is a + // background request that returns immediately and does nothing at all when no relay is + // installed. + try { + Continuity.pollRelay(); + } catch (Throwable t) { + Log.e(t); + } + } + + @Override + public void onPause() { + } + + @Override + public void onDestroy() { + } + + @Override + public void onSaveInstanceState(Bundle b) { + try { + Continuity.checkpoint(); + } catch (Throwable t) { + // Never allowed to escape. This runs on Android's main thread inside a platform + // callback, and an exception here takes down the activity as it is being saved -- + // turning a missed checkpoint into a crash on every suspend. + Log.e(t); + } + } + + @Override + public void onLowMemory() { + } + } +} diff --git a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties index 22514ddf0d9..c79fb62dcd6 100644 --- a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties +++ b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties @@ -5,7 +5,7 @@ # # A classpath entry can only carry one copy of this resource, so the # subsystems are declared as prefixed groups rather than as separate files. -groups=bluetooth,health,call,vpn +groups=bluetooth,health,call,vpn,continuity bluetooth.name=Bluetooth bluetooth.namespace=bluetooth @@ -153,3 +153,51 @@ vpn.label6=Make VPN Unsupported # API-only items below (no label): for tests and scripts. vpn.item7=com.codename1.impl.javase.VpnSimulatorHooks#makeVpnSupported + +continuity.name=Continuity +continuity.namespace=continuity + +# The whole feature in one click: whatever the app is currently advertising +# is handed straight back to it, as a second device would. Does nothing when +# no checkpoint has been taken, which is itself the answer to "why is +# nothing being offered". +continuity.item1=com.codename1.impl.javase.ContinuitySimulatorHooks#continueHere +continuity.label1=Continue Here (As Another Device) + +continuity.item2=com.codename1.impl.javase.ContinuitySimulatorHooks#checkpointNow +continuity.label2=Take A Checkpoint Now + +# A screen went away in a rebuild and the states already sitting on the +# user's other devices still name it. The restore has to survive on the +# frames it can still build. +continuity.item3=com.codename1.impl.javase.ContinuitySimulatorHooks#continueWithAStaleRoute +continuity.label3=Continue A Route This Build Dropped + +# What an app that does not use @Route produces. The framework shows +# nothing on its own here, and an app that assumed restore() always shows +# something finds out now. +continuity.item4=com.codename1.impl.javase.ContinuitySimulatorHooks#continuePayloadOnly +continuity.label4=Continue With No Routes (Payload Only) + +continuity.item5=com.codename1.impl.javase.ContinuitySimulatorHooks#continueSomethingStale +continuity.label5=Continue Something From Yesterday + +# Carries no values on any platform, so an app that re-reads only the key it +# assumed changed reads a stale one here. +continuity.item6=com.codename1.impl.javase.ContinuitySimulatorHooks#changeTheSyncedStore +continuity.label6=Change The Synced Store Elsewhere + +# What every non-Apple platform reports. An app that put a required setting +# in the synced store and never checked isSupported() loses it here. +continuity.item7=com.codename1.impl.javase.ContinuitySimulatorHooks#makeTheSyncedStoreUnsupported +continuity.label7=Make The Synced Store Unsupported + +continuity.item8=com.codename1.impl.javase.ContinuitySimulatorHooks#makeContinuationUnsupported +continuity.label8=Make Continuation Unsupported + +continuity.item9=com.codename1.impl.javase.ContinuitySimulatorHooks#makeEverythingSupported +continuity.label9=Make Everything Supported Again + +# API-only items below (no label): for tests and scripts. +continuity.item10=com.codename1.impl.javase.ContinuitySimulatorHooks#clearStoredState +continuity.item11=com.codename1.impl.javase.ContinuitySimulatorHooks#clearTheSyncedStore diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java new file mode 100644 index 00000000000..14cad9821ef --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.StateCodec; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.impl.continuity.LocalContinuityBridge; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Simulator hooks that script state restoration and continuity. +/// +/// Registered in `META-INF/codenameone/simulator-hooks.properties`. The labelled ones become a +/// Simulate menu; every one is callable from a test with `CN.execute("continuity:itemN")`. +/// +/// #### These reproduce traps, not happy paths +/// +/// Restoring cleanly is what the app does anyway. What is worth a click is the state that arrives +/// while the user is midway through something, the one that arrives before there is a form to show +/// it on, the one from a build whose routes have since been renamed, and the synced store changing +/// underneath a screen that already read it. Each is a real device behaviour an app written +/// against the cheerful path gets wrong, and each is otherwise reachable only by arranging two +/// devices. +public final class ContinuitySimulatorHooks { + + private ContinuitySimulatorHooks() { + } + + private static LocalContinuityBridge bridge() { + return JavaSEPort.getSimulatedContinuity(); + } + + /// Hands what this app is currently advertising straight back to it, as though the user had + /// picked it up on a second device. + /// + /// This is the whole feature in one click: publish, then continue. It does nothing when the + /// app has not taken a checkpoint yet, which is itself the answer to "why is nothing being + /// offered". + public static void continueHere() { + bridge().simulateArrival(); + } + + /// Delivers a state that names a route the build no longer has. + /// + /// An app is rebuilt and a screen goes away, and the states already sitting on the user's + /// other devices still name it. The restore has to survive that with the frames it can still + /// build rather than losing the session over one screen the user was not even on. + public static void continueWithAStaleRoute() { + AppState state = new AppState(); + List routes = new ArrayList(); + routes.add("/a-route-this-build-no-longer-has"); + state.setRoutes(routes) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis()) + .setTitle("From a older build"); + deliver(state); + } + + /// Delivers a state whose payload is present but whose route stack is empty, which is what an + /// app that does not use `@Route` produces. + /// + /// The framework restores nothing on its own here: the payload goes to the `StateProvider` and + /// showing a form is the app's job. An app that assumed `restore()` always shows something + /// finds out here rather than on a customer's phone. + public static void continuePayloadOnly() { + AppState state = new AppState(); + Map payload = new HashMap(); + payload.put("simulated", Boolean.TRUE); + state.setPayload(payload) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis()) + .setTitle("Payload only"); + deliver(state); + } + + /// Delivers a state that is a day old. + /// + /// Exercises `Continuity.setMaxAge(long)` and, more usefully, the listener that has to decide + /// whether moving the user somewhere they were yesterday is a courtesy or an ambush. + public static void continueSomethingStale() { + AppState state = new AppState(); + state.setRoutes(currentRoutes()) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis() - 86400000L) + .setTitle("From yesterday"); + deliver(state); + } + + /// Reports that the synced store changed on another device, without changing a value. + /// + /// The notification carries no values on any platform, so an app that assumed it did -- and + /// only re-reads the key it thinks changed -- reads a stale one here. + public static void changeTheSyncedStore() { + bridge().simulateStoreChange(); + } + + /// Makes the synced store report itself unsupported, which is what every non-Apple platform + /// does. + /// + /// An app that put a required setting in there and never checked `isSupported()` loses it + /// here, silently, exactly as it would on Android. + public static void makeTheSyncedStoreUnsupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() { + @Override + public boolean isSyncedStoreSupported() { + return false; + } + }); + } + + /// Makes continuation report itself unsupported, which is what every non-Apple platform does. + public static void makeContinuationUnsupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() { + @Override + public boolean isContinuationSupported() { + return false; + } + }); + } + + /// Restores the fully capable simulated platform. + public static void makeEverythingSupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge()); + } + + /// Takes a checkpoint now, so the menu items above have something to hand back. + public static void checkpointNow() { + Continuity.checkpoint(); + } + + /// Forgets the stored state, the way a logout does. + public static void clearStoredState() { + Continuity.clear(); + } + + /// Empties the simulated synced store. + public static void clearTheSyncedStore() { + String[] keys = SyncedStore.keys(); + for (int i = 0; i < keys.length; i++) { + SyncedStore.remove(keys[i]); + } + } + + private static List currentRoutes() { + List routes = new ArrayList(); + List stack = + com.codename1.router.Navigation.getStack(); + for (int i = 0; i < stack.size(); i++) { + routes.add(stack.get(i).getPath()); + } + if (routes.isEmpty()) { + routes.add("/"); + } + return routes; + } + + private static void deliver(AppState state) { + // Through the bridge rather than through Continuity directly, so the item exercises the + // same inbound path a device uses -- including the activity-type check, which is where a + // mismatch between the build's declared type and the framework's would show up. + bridge().simulateArrival(Continuity.getActivityType(), StateCodec.toMap(state)); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 2540198e600..7ad82ad08fb 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16590,6 +16590,8 @@ public com.codename1.health.Health getHealth() { private static com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + private static com.codename1.impl.continuity.LocalContinuityBridge continuityBridge; + private static com.codename1.impl.call.LocalCallBridge callBridge; private static com.codename1.impl.vpn.LocalVpnBridge vpnBridge; @@ -16668,6 +16670,55 @@ public static com.codename1.impl.call.LocalCallBridge getSimulatedCalls() { } } + /// The continuity bridge for the simulator and desktop builds. + /// + /// A simulated one rather than none, for the reason + /// [#getCallBridge()] carries one: an app's continuity work is deciding + /// what belongs in the payload, prompting before a jump and rebuilding a + /// screen from a route, none of which has anything to do with the + /// operating system that carries the state. A port that reported nothing + /// would make every bit of it testable only on a pair of phones. + /// + /// The simulated synced store is backed by `Preferences`, so it survives + /// a simulator restart the way the platform store survives a device one. + @Override + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return getSimulatedContinuity(); + } + + /// The simulated continuity platform, for the Simulate menu to script. + /// + /// Static and class-guarded for the reason the call bridge is: it holds + /// the advertised activity and the framework's callback, and two threads + /// racing this getter would each get their own -- an activity published + /// through one would be invisible to the menu item that hands it back. + public static com.codename1.impl.continuity.LocalContinuityBridge getSimulatedContinuity() { + synchronized (JavaSEPort.class) { + if (continuityBridge == null) { + continuityBridge = new com.codename1.impl.continuity.LocalContinuityBridge(); + } + return continuityBridge; + } + } + + /// Replaces the simulated continuity platform, so the Simulate menu can + /// swap in one that reports a capability as missing. + /// + /// The framework's inbound seam is re-installed on the replacement: + /// without that the new bridge would have no callback, and every menu + /// item that delivers a continuation would silently do nothing. + /// + /// #### Parameters + /// + /// - `b`: the replacement, never null + public static void setSimulatedContinuity( + com.codename1.impl.continuity.LocalContinuityBridge b) { + synchronized (JavaSEPort.class) { + continuityBridge = b; + } + com.codename1.continuity.Continuity.refreshBridge(); + } + /// The VPN bridge for the simulator and desktop builds: a simulated /// configuration store that tunnels nothing. /// diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index ac569f96dcc..478e3354210 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -110,6 +110,13 @@ // where this class is not translated and the header does not exist. #import "com_codename1_impl_ios_IOSIntentCallbacks.h" #endif +#ifdef CN1_USE_CONTINUITY +// Same reasoning as the intents header above, and the same guard: the continuity branch below +// calls a translated entry point, and an implicit declaration is a hard error on some slices and +// a wrong-registers call on the rest. CodenameOne_GLViewController.h undefines +// CN1_USE_CONTINUITY for watchOS and tvOS, where this class is not translated. +#import "com_codename1_impl_ios_IOSContinuityCallbacks.h" +#endif // A signal handler to handle bad accesses. This will throw NPEs that we can catch // rather than crashing the app. @@ -279,6 +286,47 @@ - (BOOL)cn1ContinueUserActivity:(NSUserActivity *)userActivity #endif return YES; } +#ifdef CN1_USE_CONTINUITY + // Continuity is matched BEFORE intents, and the order is load-bearing. The intents block + // below ends in a general branch that hands any remaining activity type to Java and returns + // Java's answer -- and Intents.dispatchUserActivity correctly answers NO for a type it never + // declared. An app using both would therefore have its own continuation asked about by the + // wrong framework, told no, and dropped. Matching here first keeps each framework answering + // only for the types it published. + // + // Placed after the browsing-web branch rather than before it for the reason that branch is + // first: Universal Link behaviour must be bit-identical whether or not continuity is in play. + if (userActivity != nil && [userActivity.activityType hasSuffix:@".continuity"]) { + NSString *payload = nil; + if (userActivity.userInfo != nil + && [NSJSONSerialization isValidJSONObject:userActivity.userInfo]) { + NSData *data = [NSJSONSerialization dataWithJSONObject:userActivity.userInfo + options:0 error:nil]; + if (data != nil) { + // Autoreleased: the app target is manual-reference-counted and this method + // returns without a release, so every continuation would otherwise retain its + // serialized payload for the life of the process. + payload = [[[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding] autorelease]; + } + } + JAVA_OBJECT jtype = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG userActivity.activityType); + JAVA_OBJECT jpayload = payload == nil ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG payload); +#ifdef NEW_CODENAME_ONE_VM + JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload); +#else + JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload); +#endif + if (claimed == JAVA_TRUE) { + return YES; + } + // Not claimed: the suffix matched but the framework did not recognize the type as its + // own, which is what a third-party activity whose type happens to end the same way looks + // like. Falls through rather than returning NO, so the intents branch below still gets + // its chance at it. + } +#endif #ifdef CN1_USE_INTENTS // Everything below is compiled only for an app that references // com.codename1.intents, so a build without it produces exactly the function above. @@ -523,13 +571,19 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( if (activityDictionary) { NSUserActivity *userActivity = [activityDictionary valueForKey:@"UIApplicationLaunchOptionsUserActivityKey"]; if (userActivity != nil) { -#ifdef CN1_USE_INTENTS - // A donated activity cold-launching the app arrives here, before the VM - // callback below has run the application's init/start -- so the framework's - // dispatcher exists (the generated bootstrap installed it from main) while - // Display does not, and the handler would run inline with no event thread and - // no window. Held until initialization instead; browsing-web keeps its existing - // path, which only stores AppArg and is safe this early. +#if defined(CN1_USE_INTENTS) || defined(CN1_USE_CONTINUITY) + // A donated activity or a continuation cold-launching the app arrives here, + // before the VM callback below has run the application's init/start -- so the + // framework's dispatcher exists (the generated bootstrap installed it from main) + // while Display does not, and the handler would run inline with no event thread + // and no window. Held until initialization instead; browsing-web keeps its + // existing path, which only stores AppArg and is safe this early. + // + // Continuity needs the hold for a second reason of its own: its Java callback is + // installed by Continuity.enable(), which the application calls from init(). An + // activity delivered before that finds no callback at all and is dropped -- so an + // app that used continuity WITHOUT intents used to lose exactly the cold launch + // the feature exists for. if (![NSUserActivityTypeBrowsingWeb isEqualToString:userActivity.activityType]) { cn1PendingLaunchActivity = [userActivity retain]; } else { diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index e8d27687bea..10a112284a3 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -222,6 +222,16 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // FileProvider symbols at all. //#define CN1_USE_DOCUMENTS +// CN1_USE_CONTINUITY gates state restoration and cross-device continuity: the IOSNative +// continuity* implementations (NSUserActivity for handing work to a nearby device, +// NSUbiquitousKeyValueStore for the synced store) plus the continuity branch in +// CodenameOne_GLAppDelegate. IPhoneBuilder uncomments this only when the classpath scanner saw +// com.codename1.continuity.*, so an app that restores nothing links neither. +// +// Note what this define does NOT gate: saving and restoring state on this device is pure Java +// over com.codename1.io.Storage and works in every build, define or no define. +//#define CN1_USE_CONTINUITY + // CN1_APP_INTENTS_DECLARED is the narrower question: did the build actually generate App Intent // declarations? CN1_USE_INTENTS only says the app references the package, and an app can use // indexing and donation while switching declarations off with ios.intents.appIntents=false. @@ -232,9 +242,13 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, //#define CN1_APP_INTENTS_DECLARED // Core Spotlight and App Intents are unavailable on watchOS / tvOS; undo the defines there. +// Continuity goes with them: NSUserActivity handoff has no watchOS or tvOS counterpart, and +// NSUbiquitousKeyValueStore is unavailable on both. The Java half is unaffected -- a watch app +// still saves and restores its own state, which is the half that needs no native support. #if TARGET_OS_WATCH || TARGET_OS_TV #undef CN1_USE_INTENTS #undef CN1_APP_INTENTS_DECLARED +#undef CN1_USE_CONTINUITY #endif // CN1_USE_WATCHCONNECTIVITY gates the phone-to-watch link (CN1WatchConnectivity.{h,m} + the diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 17165743dec..e75757bb71a 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20016,6 +20016,303 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole return com_codename1_impl_ios_IOSNative_intentsIndexingSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); } + +// --- State restoration and continuity (com.codename1.continuity) ------------- +// +// Two unrelated Apple mechanisms, gated together on CN1_USE_CONTINUITY but answered separately +// to Java, because they cost different things. NSUserActivity with eligibleForHandoff carries +// what the user is doing to a device they are holding and needs no entitlement at all; the +// NSUbiquitousKeyValueStore below carries a few durable values to every device on the account +// and needs one that has to be granted on the App ID. An app wanting only the first must not be +// made to arrange the second, which is why com.codename1.continuity.sync is a separate package +// and why the store reports its own availability rather than assuming it. +// +// What is NOT here: saving and restoring state on this device. That is pure Java over +// com.codename1.io.Storage and works in every build, with or without this define. + +#ifdef CN1_USE_CONTINUITY + +/// The advertised activity, or nil. A single slot rather than the bounded ring the intent +/// donations use: a donation is a historical fact the system may keep offering, while this is +/// "what the user is doing right now" and there is only ever one of those. Publishing again +/// replaces it. +static NSUserActivity *cn1ContinuityActivity = nil; + +/// Retained so the observer can be reasoned about, though nothing ever removes it: the store's +/// external-change notification is wanted for the entire life of the process. +static id cn1ContinuityStoreObserver = nil; + +// The translated entry point the store observer below calls. Without this it is an implicit +// declaration, which C99 and every clang that enforces it reject outright -- and where a +// toolchain still accepts one, the invented prototype passes the thread state through whatever +// registers the default promotions choose. Same reasoning, and the same fix, as the +// IOSWearableCallbacks declarations further down this file. +extern JAVA_VOID com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__( + CODENAME_ONE_THREAD_STATE); + +static NSDictionary *cn1ContinuityParseJson(NSString *json) { + if (json == nil) { + return nil; + } + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSDictionary class]] ? (NSDictionary *)parsed : nil; +} + +/// Reduces a parsed JSON value to what a property list can hold, recursively. +/// +/// The intent donation path beside this one flattens to strings and numbers, which is all a +/// donation carries. A continuity payload is nested by construction -- an array of route paths +/// and a map of the application's own values -- so flattening it would deliver an activity with +/// the routes silently missing, which looks exactly like the feature not working. +/// +/// Anything with no property-list representation, NSNull included, is dropped rather than +/// substituted: the Java side validated the payload where the application produced it, so the +/// only values that can reach here are ones JSON introduced on its own. +static id cn1ContinuitySanitize(id value) { + if ([value isKindOfClass:[NSString class]] || [value isKindOfClass:[NSNumber class]]) { + return value; + } + if ([value isKindOfClass:[NSArray class]]) { + NSMutableArray *out = [NSMutableArray array]; + for (id item in (NSArray *)value) { + id safe = cn1ContinuitySanitize(item); + if (safe != nil) { + [out addObject:safe]; + } + } + return out; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + NSDictionary *dict = (NSDictionary *)value; + for (id key in dict) { + if (![key isKindOfClass:[NSString class]]) { + continue; + } + id safe = cn1ContinuitySanitize([dict objectForKey:key]); + if (safe != nil) { + [out setObject:safe forKey:key]; + } + } + return out; + } + return nil; +} + +/// The synced store, or nil when this build did not earn one. +/// +/// Resolved once and cached, because the answer cannot change while the process runs: it is a +/// property of how the app was signed. +/// +/// Three guards rather than one, and deliberately so. The entitlement is missing in the ordinary +/// case that an app references com.codename1.continuity.sync and the App ID never had iCloud +/// enabled, and what that produces has not been the same across releases of iOS -- a nil store, a +/// store whose synchronize answers NO, and a raised exception have all been reported. Guessing +/// which one this OS does would leave the app writing values into nothing on the others, and the +/// symptom of that is a setting that silently fails to follow the user. +static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) { + static NSUbiquitousKeyValueStore *store = nil; + static BOOL resolved = NO; + if (resolved) { + return store; + } + resolved = YES; + @try { + NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore]; + if (s != nil && [s synchronize]) { + store = [s retain]; + cn1ContinuityStoreObserver = [[[NSNotificationCenter defaultCenter] + addObserverForName:NSUbiquitousKeyValueStoreDidChangeExternallyNotification + object:s + queue:nil + usingBlock:^(NSNotification *note) { + com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + }] retain]; + } + } @catch (NSException *e) { + store = nil; + } + return store; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_TRUE; +} + +void com_codename1_impl_ios_IOSNative_continuityPublish___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityType, JAVA_OBJECT title, JAVA_OBJECT userInfoJson) { + if (activityType == JAVA_NULL) { + return; + } + POOL_BEGIN(); + NSString *type = toNSString(CN1_THREAD_STATE_PASS_ARG activityType); + NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:type]; + // The one property that makes this a continuation rather than a donation. Without it the + // activity is only a Siri/Spotlight hint and no other device is ever offered it -- which is + // exactly the shape of the intents path beside this one, and the reason the two do not share + // a code path despite building the same class. + activity.eligibleForHandoff = YES; + if (title != JAVA_NULL) { + NSString *label = toNSString(CN1_THREAD_STATE_PASS_ARG title); + if ([label length] > 0) { + activity.title = label; + } + } + if (userInfoJson != JAVA_NULL) { + id safe = cn1ContinuitySanitize(cn1ContinuityParseJson( + toNSString(CN1_THREAD_STATE_PASS_ARG userInfoJson))); + if ([safe isKindOfClass:[NSDictionary class]]) { + activity.userInfo = (NSDictionary *)safe; + } + } + [activity becomeCurrent]; + // Ownership of the alloc's reference moves into the slot; the previous occupant is + // invalidated so the system stops offering a state the app has moved on from, and then + // released, since this slot held the only reference to it in a manual-reference-counted + // target. + NSUserActivity *previous = cn1ContinuityActivity; + cn1ContinuityActivity = activity; + if (previous != nil) { + [previous invalidate]; + [previous release]; + } + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_continuityClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (cn1ContinuityActivity == nil) { + return; + } + POOL_BEGIN(); + NSUserActivity *activity = cn1ContinuityActivity; + // Cleared before the messages, so a second call cannot resign and release the same activity + // twice -- which in a manual-reference-counted target is an over-release, not a no-op. + cn1ContinuityActivity = nil; + [activity resignCurrent]; + [activity invalidate]; + [activity release]; + POOL_END(); +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return cn1ContinuityStore() != nil ? JAVA_TRUE : JAVA_FALSE; +} + +void com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key, JAVA_OBJECT value) { + NSUbiquitousKeyValueStore *store = cn1ContinuityStore(); + if (store == nil || key == JAVA_NULL || value == JAVA_NULL) { + return; + } + POOL_BEGIN(); + [store setString:toNSString(CN1_THREAD_STATE_PASS_ARG value) + forKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)]; + // Asked for rather than waited on. The system syncs on its own schedule and this only moves + // it along; the return value says whether the store is usable at all, which cn1ContinuityStore + // already established. + [store synchronize]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) { + NSUbiquitousKeyValueStore *store = cn1ContinuityStore(); + if (store == nil || key == JAVA_NULL) { + return JAVA_NULL; + } + JAVA_OBJECT result = JAVA_NULL; + POOL_BEGIN(); + NSString *value = [store stringForKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)]; + if (value != nil) { + result = fromNSString(CN1_THREAD_STATE_PASS_ARG value); + } + POOL_END(); + return result; +} + +void com_codename1_impl_ios_IOSNative_continuitySyncedStoreRemove___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) { + NSUbiquitousKeyValueStore *store = cn1ContinuityStore(); + if (store == nil || key == JAVA_NULL) { + return; + } + POOL_BEGIN(); + [store removeObjectForKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)]; + [store synchronize]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + NSUbiquitousKeyValueStore *store = cn1ContinuityStore(); + if (store == nil) { + return JAVA_NULL; + } + JAVA_OBJECT result = JAVA_NULL; + POOL_BEGIN(); + NSArray *keys = [[store dictionaryRepresentation] allKeys]; + NSMutableArray *strings = [NSMutableArray array]; + for (id key in keys) { + if ([key isKindOfClass:[NSString class]]) { + [strings addObject:key]; + } + } + // Wrapped in an object because the Java side parses it with JSONParser, whose entry point + // reads a document whose root is an object. A bare array would parse to nothing. + NSDictionary *doc = [NSDictionary dictionaryWithObject:strings forKey:@"keys"]; + NSData *data = [NSJSONSerialization isValidJSONObject:doc] + ? [NSJSONSerialization dataWithJSONObject:doc options:0 error:nil] : nil; + if (data != nil) { + NSString *json = [[[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding] autorelease]; + result = fromNSString(CN1_THREAD_STATE_PASS_ARG json); + } + POOL_END(); + return result; +} + +#else // CN1_USE_CONTINUITY + +// Continuity not enabled: no NSUserActivity or iCloud references, everything unsupported. The +// on-device half of the framework is unaffected, being pure Java. +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +void com_codename1_impl_ios_IOSNative_continuityPublish___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityType, JAVA_OBJECT title, JAVA_OBJECT userInfoJson) { +} +void com_codename1_impl_ios_IOSNative_continuityClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +void com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key, JAVA_OBJECT value) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_continuitySyncedStoreRemove___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +#endif // CN1_USE_CONTINUITY + +// New-VM (return-type-encoded) manglings for the value-returning continuity natives. Defined +// after the implementations/stubs above so each call targets an already-declared function. The +// void continuity* methods need no _R_ wrapper. Always defined regardless of CN1_USE_CONTINUITY. +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT key) { + return com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, key); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} + // --- Phone-to-watch link (com.codename1.wearable / WatchConnectivity) -------- // // Compiled into BOTH the phone target and the watch target: WCSession is symmetric, so the two diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java new file mode 100644 index 00000000000..7f1bcb83a63 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.JSONParser; +import com.codename1.io.JSONWriter; +import com.codename1.io.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Apple's half of the continuity framework: `NSUserActivity` for handing work to a device the +/// user is holding, and `NSUbiquitousKeyValueStore` for the handful of values that should follow +/// them everywhere. +/// +/// #### The two halves are independent +/// +/// Advertising an activity costs nothing but a declared activity type in the app's `Info.plist`. +/// The synced store costs an entitlement, which has to be granted on the App ID before the app +/// will sign at all. `isSyncedStoreSupported()` therefore asks the native side rather than +/// returning a constant: a build that did not earn the entitlement has no store, and answering +/// "yes" would have the app writing values that silently go nowhere. +/// +/// #### Everything crosses as JSON +/// +/// Matching the intent natives beside these. The payload has to become an `NSDictionary` the +/// system will accept in an activity's `userInfo`, and doing that conversion once, in C, against +/// a parsed JSON document is simpler than a per-type native call and is the same shape the rest of +/// this port already uses. +class IOSContinuityBridge implements ContinuityBridge { + private final IOSNative nativeInterface; + private final boolean supported; + + IOSContinuityBridge(IOSNative n) { + nativeInterface = n; + boolean s; + try { + s = n.continuitySupported(); + } catch (Throwable t) { + Log.e(t); + s = false; + } + supported = s; + } + + public void setCallback(ContinuityCallback callback) { + IOSContinuityCallbacks.setCallback(callback); + } + + public boolean isContinuationSupported() { + return supported; + } + + public void publishContinuation(String activityType, String title, + Map userInfo) { + if (!supported) { + return; + } + try { + nativeInterface.continuityPublish(activityType, title, + userInfo == null ? null : JSONWriter.toJson(userInfo)); + } catch (Throwable t) { + Log.e(t); + } + } + + public void clearContinuation() { + if (!supported) { + return; + } + try { + nativeInterface.continuityClear(); + } catch (Throwable t) { + Log.e(t); + } + } + + public boolean isSyncedStoreSupported() { + if (!supported) { + return false; + } + try { + return nativeInterface.continuitySyncedStoreSupported(); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + public void syncedStorePut(String key, String value) { + if (!isSyncedStoreSupported()) { + return; + } + try { + nativeInterface.continuitySyncedStorePut(key, value); + } catch (Throwable t) { + Log.e(t); + } + } + + public String syncedStoreGet(String key) { + if (!isSyncedStoreSupported()) { + return null; + } + try { + return nativeInterface.continuitySyncedStoreGet(key); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + public void syncedStoreRemove(String key) { + if (!isSyncedStoreSupported()) { + return; + } + try { + nativeInterface.continuitySyncedStoreRemove(key); + } catch (Throwable t) { + Log.e(t); + } + } + + public String[] syncedStoreKeys() { + if (!isSyncedStoreSupported()) { + return new String[0]; + } + // The native call and the parse are what can fail, so they are what the handler covers. + // Everything below it is deliberately outside: the compiler inserts checked casts for the + // generic element type and for toArray's component type, and a failed cast does not throw + // on this virtual machine -- so a handler wrapped around one is a handler that cannot run + // here. See the ClassCastException note in CLAUDE.md. + Map parsed; + try { + String json = nativeInterface.continuitySyncedStoreKeys(); + if (json == null || json.length() == 0) { + return new String[0]; + } + parsed = JSONParser.parseJSON(json); + } catch (Throwable t) { + Log.e(t); + return new String[0]; + } + Object keys = parsed == null ? null : parsed.get("keys"); + if (!(keys instanceof List)) { + return new String[0]; + } + List read = (List) keys; + List out = new ArrayList(); + for (int i = 0; i < read.size(); i++) { + Object key = read.get(i); + if (key instanceof String) { + out.add((String) key); + } + } + return out.toArray(new String[out.size()]); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java new file mode 100644 index 00000000000..24ce5a0b774 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; + +import java.util.HashMap; +import java.util.Map; + +/// Static callback surface the native continuity glue calls into. +/// +/// #### Why the static initializer calls everything once +/// +/// ParparVM's dead-code eliminator decides a Java method is reachable by scanning the `.m` sources +/// for its mangled symbol and by following Java call graphs. These methods have no Java caller, and +/// the failure mode when they are stripped is not a link error -- they translate to empty stubs and +/// the native dispatch silently does nothing, so the build is green and continuations never arrive. +/// The guarded self-call in the static initializer is what keeps them alive. +/// +/// The call must be unconditional. Wrapping it in an `if` the optimizer can prove false folds the +/// whole thing away and reintroduces the bug. +final class IOSContinuityCallbacks { + private static ContinuityCallback callback; + private static boolean dceGuard; + + /// A continuation that arrived before the framework was enabled, and the type it arrived + /// under. Only ever one: a cold launch delivers a single activity, and a second arrival means + /// the app is running and the callback is installed. + private static String pendingType; + private static String pendingJson; + + static { + // Keep the native callback targets reachable for the iOS VM optimizer. + dceGuard = true; + nativeContinuation(null, null); + nativeSyncedStoreChanged(); + dceGuard = false; + } + + private IOSContinuityCallbacks() { + } + + static void setCallback(ContinuityCallback c) { + callback = c; + String type = pendingType; + String json = pendingJson; + pendingType = null; + pendingJson = null; + if (c != null && type != null) { + // A continuation that cold-launched the app can reach this class before the + // application's init() has called Continuity.enable(), which is what installs the + // callback -- the scene delegate hands it over from willConnectToSession, which runs + // first. Delivered now instead of dropped, which is what the whole feature is for. + try { + c.continuationReceived(type, parse(json)); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// An `NSUserActivity` of this app's continuity type arrived. + /// + /// #### Returns + /// + /// true when the framework claimed it, so the delegate can answer the system honestly rather + /// than swallowing an activity this app never published + public static boolean nativeContinuation(String activityType, String userInfoJson) { + if (dceGuard) { + return false; + } + ContinuityCallback c = callback; + if (c == null) { + // The framework has not been enabled yet. That is the ordinary cold-launch ordering + // rather than a mistake, so the activity is held for setCallback to deliver instead + // of being dropped. + // + // Claimed all the same. The delegate's answer decides whether the activity falls + // through to the intents branch beside it, and one this app is about to act on must + // not: an app using both frameworks would otherwise have its own continuation offered + // to the wrong one, which would correctly decline it, and the launch would land on the + // home screen. + pendingType = activityType; + pendingJson = userInfoJson; + return true; + } + try { + return c.continuationReceived(activityType, parse(userInfoJson)); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + /// The synced store changed on another of the user's devices. + public static void nativeSyncedStoreChanged() { + if (dceGuard) { + return; + } + ContinuityCallback c = callback; + if (c == null) { + return; + } + try { + c.syncedStoreChanged(); + } catch (Throwable t) { + Log.e(t); + } + } + + private static Map parse(String json) { + if (json == null || json.length() == 0) { + return new HashMap(); + } + try { + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? new HashMap() : parsed; + } catch (Throwable t) { + Log.e(t); + return new HashMap(); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index c8cfe878a4c..eb8043156c4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -493,6 +493,21 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return documentProviderBridge; } + private IOSContinuityBridge continuityBridge; + + @Override + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + // Only meaningful in builds that linked the continuity natives (CN1_USE_CONTINUITY, + // flipped by the builder when the app references com.codename1.continuity). Always + // returned: the bridge asks the native side once and answers honestly, and the native + // stubs to unsupported when the define is off. Returning null instead would also disable + // the on-device half of the framework, which needs no native support at all. + if (continuityBridge == null) { + continuityBridge = new IOSContinuityBridge(nativeInstance); + } + return continuityBridge; + } + private IOSIntentBridge intentBridge; @Override diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index bf685409486..3cdfea3fa51 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1372,6 +1372,45 @@ native void surfacesMirrorToWatch(String kindId, String timelineJson, */ native void intentsCompleteInvocation(String token, String resultJson); + // --- State restoration and continuity ----------------------------------- + // Backs com.codename1.continuity. Two unrelated Apple mechanisms sit behind these and are + // answered separately: NSUserActivity carries the current activity to a device that is + // physically nearby, and NSUbiquitousKeyValueStore carries a few durable values to every + // device on the account whether they are nearby or not. The first needs no entitlement and + // the second needs one, which is why com.codename1.continuity.sync is a package of its own. + // Payloads cross as JSON strings, matching the intents natives above. + + /** True when this build linked the continuation natives at all. */ + native boolean continuitySupported(); + + /** + * Advertises the current activity to the user's nearby devices, replacing whatever was + * advertised before. The JSON is the state; the title is what the receiving device shows. + */ + native void continuityPublish(String activityType, String title, String userInfoJson); + + /** Withdraws the advertised activity. */ + native void continuityClear(); + + /** True when this build linked the synced store and the entitlement granted one. */ + native boolean continuitySyncedStoreSupported(); + + /** Writes a value to the synced store. */ + native void continuitySyncedStorePut(String key, String value); + + /** Reads a value from the synced store, or null when the key is absent. */ + native String continuitySyncedStoreGet(String key); + + /** Removes a key from the synced store. */ + native void continuitySyncedStoreRemove(String key); + + /** + * Every key in the synced store, as {@code {"keys":["a","b"]}}. A JSON document rather than + * a {@code String[]} because every other native here exchanges JSON, and because a store key + * is an application-chosen string that no separator character is safe against. + */ + native String continuitySyncedStoreKeys(); + // --- Phone-to-watch link (WatchConnectivity) ---------------------------- // Backs com.codename1.wearable. The same natives serve both halves of a pair: WCSession is // symmetric, so the phone app and the watch app run identical code. Payloads cross as opaque diff --git a/Samples/samples/ContinuitySample/ContinuitySample.java b/Samples/samples/ContinuitySample/ContinuitySample.java new file mode 100644 index 00000000000..fa674c1f4a3 --- /dev/null +++ b/Samples/samples/ContinuitySample/ContinuitySample.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.samples; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.ContinuityListener; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.continuity.sync.SyncedStoreListener; +import com.codename1.ui.Button; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import java.util.HashMap; +import java.util.Map; + +/** + * Demonstrates {@code com.codename1.continuity}: keeping the user's work across a process death, + * and handing it to another device they own. + * + *

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

+ * + *

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

+ */ +public class ContinuitySample { + + private Form current; + private Resources theme; + + /** The whole of this app's state. Read by the provider, written by the field. */ + private String draft = ""; + + /** Where the field was scrolled to, which is the sort of thing a route cannot carry. */ + private int caret; + + private TextArea field; + private Label status; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + + // Installing a provider is what turns the framework on. Nothing before this line has any + // effect, which is what keeps an app that does not use continuity behaving as it always + // did. + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draft); + state.put("caret", Integer.valueOf(caret)); + return state; + } + + public void restoreState(Map state) { + Object savedDraft = state.get("draft"); + if (savedDraft instanceof String) { + draft = (String) savedDraft; + } + Object savedCaret = state.get("caret"); + // instanceof rather than a cast: a state that crossed from another device came + // through JSON, where every number is a Double, and a failed cast does not throw + // on the iOS virtual machine. + if (savedCaret instanceof Number) { + caret = ((Number) savedCaret).intValue(); + } + } + }); + + // Ask before moving the user. Jumping them somewhere without warning is the wrong default + // for anything they might be midway through, and holding the state is a one-liner. + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(final AppState state) { + String label = state.getTitle() == null ? "your other device" : state.getTitle(); + if (Dialog.show("Continue?", "Pick up \"" + label + "\"?", "Continue", "Stay")) { + Continuity.restore(state); + showDraftForm(); + } + // Consumed either way: the decision has been made here, so no other listener is + // asked and nothing is restored behind this one's back. + return false; + } + }); + + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + refreshStatus(); + } + }); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + // "Restore, or else begin". This app records no routes, so restore() hands the payload to + // the provider and answers false -- the screen is still this app's to show. + Continuity.restore(); + showDraftForm(); + } + + public void stop() { + current = Display.getInstance().getCurrent(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + current = Display.getInstance().getCurrent(); + } + } + + public void destroy() { + } + + private void showDraftForm() { + Form form = new Form("Continuity", BoxLayout.y()); + + field = new TextArea(draft, 5, 40); + field.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + } + }); + form.add(new Label("Type something, then continue it elsewhere:")); + form.add(field); + + status = new Label(""); + form.add(status); + + Button checkpoint = new Button("Save a checkpoint now"); + checkpoint.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + Dialog.show("Saved", "Advertised as \"" + Continuity.getTitle() + "\".", "OK", null); + } + }); + form.add(checkpoint); + + Button remember = new Button("Remember this device's choice"); + remember.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + // A write that reports whether it happened, because the store does not exist on + // most platforms and is finite where it does. + if (!SyncedStore.put("lastEditor", Display.getInstance().getPlatformName())) { + Dialog.show("No synced store", "This platform has none, so the choice stays " + + "on this device.", "OK", null); + } + refreshStatus(); + } + }); + form.add(remember); + + Button forget = new Button("Log out (forget everything)"); + forget.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + draft = ""; + caret = 0; + // The advertised activity outlives this screen, so an account's work would stay + // on offer to the devices around it without this. + Continuity.clear(); + field.setText(""); + refreshStatus(); + } + }); + form.add(forget); + + refreshStatus(); + form.show(); + } + + /** Reads the screen into the fields the provider reports, then checkpoints. */ + private void capture() { + draft = field.getText(); + caret = field.getCursorPosition(); + // A title names the WORK, not the screen: it is what another device shows the user before + // they accept. + Continuity.setTitle(draft.length() == 0 ? "An empty draft" + : "Draft: " + draft.substring(0, Math.min(24, draft.length()))); + Continuity.checkpoint(); + refreshStatus(); + } + + private void refreshStatus() { + if (status == null) { + return; + } + status.setText("continuation: " + (Continuity.isContinuationSupported() ? "yes" : "no") + + " | synced store: " + (SyncedStore.isSupported() ? "yes" : "no") + + " | last editor: " + SyncedStore.get("lastEditor", "none")); + if (status.getComponentForm() != null) { + status.getComponentForm().revalidate(); + } + } +} diff --git a/Samples/samples/ContinuitySample/codenameone_settings.properties b/Samples/samples/ContinuitySample/codenameone_settings.properties new file mode 100644 index 00000000000..d5bb6fec2da --- /dev/null +++ b/Samples/samples/ContinuitySample/codenameone_settings.properties @@ -0,0 +1,9 @@ +#Continuity sample build hints +# Declares that this project hands the user's work between their devices. The build detects the +# reference to com.codename1.continuity on its own; the hint is what lets the Certificate Wizard +# and the signing preflight know whether an iCloud capability will be wanted. +codename1.arg.ios.continuity.enabled=true +# This sample touches com.codename1.continuity.sync, so the build asks for the iCloud key-value +# store entitlement -- which the App ID has to grant. Uncomment to drop it and leave SyncedStore +# reporting itself unsupported; handing work to a nearby device is unaffected either way. +#codename1.arg.ios.continuity.sync=false diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java new file mode 100644 index 00000000000..0065ef289fe --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.continuity; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.ContinuityListener; +import com.codename1.continuity.RestStateRelay; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.continuity.sync.SyncedStoreListener; +import com.codename1.router.Navigation; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; + +import java.util.HashMap; +import java.util.Map; + +/** + * Snippets that accompany the State Restoration and Continuity guide chapter. Each block between + * the tag markers is included verbatim into the AsciiDoc. + */ +public class ContinuitySnippets { + + /** Stands in for the screen the application is showing. */ + private TextArea draftField = new TextArea(); + + /** Stands in for the application's own session object. */ + private Session session = new Session(); + + /** A state the application held back rather than acting on immediately. */ + private AppState held; + + static class Session { + String getAccessToken() { + return "a-token"; + } + } + + // tag::provider[] + public void init(Object context) { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draftField.getText()); + return state; + } + + public void restoreState(Map state) { + draftField.setText((String) state.get("draft")); + } + }); + } + // end::provider[] + + // tag::start[] + public void start() { + if (!Continuity.restore()) { + Navigation.navigate("/home"); + } + } + // end::start[] + + // tag::checkpoint[] + public void onDraftSaved() { + Continuity.setTitle("Draft to Dana"); + Continuity.checkpoint(); + } + // end::checkpoint[] + + // tag::askFirst[] + public void askBeforeMovingTheUser() { + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + held = state; + if (Dialog.show("Continue?", "Pick up \"" + state.getTitle() + + "\" from your other device?", "Continue", "Stay here")) { + Continuity.restore(held); + } + // Consumed either way: the decision has been made here. + return false; + } + }); + } + // end::askFirst[] + + // tag::relay[] + public void useMyOwnEndpoint() { + Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") { + @Override + protected String getToken() { + return session.getAccessToken(); + } + }); + } + // end::relay[] + + // tag::pollOnResume[] + public void onAppResumed() { + Continuity.pollRelay(); + } + // end::pollOnResume[] + + // tag::syncedStore[] + public String readSortOrder() { + return SyncedStore.get("sortOrder", "byName"); + } + + public void writeSortOrder(String order) { + if (!SyncedStore.put("sortOrder", order)) { + // No synced store here, or it is full. The value still has to live somewhere, so + // fall back to this device's own preferences rather than losing the choice. + com.codename1.io.Preferences.set("sortOrder", order); + } + } + // end::syncedStore[] + + // tag::syncedStoreListener[] + public void followTheStore() { + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + // No values are carried, on any platform. Re-read what this screen shows. + applySortOrder(SyncedStore.get("sortOrder", "byName")); + } + }); + } + // end::syncedStoreListener[] + + // tag::capability[] + public void describeWhatThisDeviceCanDo() { + if (Continuity.isContinuationSupported()) { + showBanner("Open this app on your other device to carry on there."); + } + } + // end::capability[] + + // tag::logout[] + public void onLogout() { + Continuity.clear(); + } + // end::logout[] + + // tag::maxAge[] + public void expireACheckout() { + Continuity.setMaxAge(15 * 60 * 1000); + } + // end::maxAge[] + + private void applySortOrder(String order) { + } + + private void showBanner(String message) { + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties new file mode 100644 index 00000000000..60908c86b1f --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties @@ -0,0 +1,9 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::state-restoration-and-continuity-properties-001[] +codename1.arg.ios.continuity.enabled=true +// end::state-restoration-and-continuity-properties-001[] + +// tag::state-restoration-and-continuity-properties-002[] +codename1.arg.ios.continuity.sync=false +// end::state-restoration-and-continuity-properties-002[] diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc new file mode 100644 index 00000000000..89bdfa31932 --- /dev/null +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -0,0 +1,306 @@ +== State Restoration and Continuity + +An app that's put in the background isn't paused. Android reclaims the process +routinely, iOS terminates a suspended app whenever it needs the memory, and in +both cases what comes back isn't the app the user left -- it's a fresh launch +that happens to be wearing the same icon. The user sees their work replaced by +the home screen and has no idea why. + +`com.codename1.continuity` saves what the user was doing and brings it back. On +Apple platforms it does one thing more: it offers that same work to the other +devices the person is signed in to, so a draft begun on the phone can be +finished on the iPad they pick up. + +The two halves cost different things, so they're two packages. +`com.codename1.continuity` holds the framework and everything that carries work +to a device the user is holding. `com.codename1.continuity.sync` holds a small +key/value store the platform keeps in step across their devices, and referencing +it earns an iOS build an entitlement. An app that wants the first shouldn't have +to arrange the second. + +[options="header"] +|=== +| Capability | iOS and macOS | Android | Simulator and desktop | JavaScript +| Restore after the process is killed | yes | yes | yes | yes +| Restore the `@Route` screen stack | yes | yes | yes | yes +| Carry on where they left off, on a device they're holding | yes | -- | simulated | -- +| A key/value store synced across devices | yes (`com.codename1.continuity.sync`) | -- | simulated | -- +| Carry state to any other device | your `StateRelay` | your `StateRelay` | your `StateRelay` | your `StateRelay` +|=== + +Branch on the capability queries -- `Continuity.isSupported()`, +`Continuity.isContinuationSupported()`, `SyncedStore.isSupported()` -- rather +than on platform detection. The first row is the one that matters most and it's +supported everywhere, because it's pure storage with no platform behind it. + +Every callback in this family arrives on the EDT. + +=== Six Things Worth Knowing Before You Design Around This + +*Nothing happens until you ask for it.* An app that never references this package +behaves exactly as it always did, and so does one that references it and never +calls `Continuity.setStateProvider` or `Continuity.enable`. `Continuity.restore()` +is never called for you either. Where restoration belongs in a launch is a +decision only the app can make, and a framework that guessed would be wrong for +the apps that care most. + +*The route stack is free; everything else is yours.* If your screens are declared +with `@Route`, the framework already knows the navigation stack and restores it +with no code from you. If your app navigates with `new MyForm().show()`, those +moves aren't addressable and there's nothing to write down -- so `restore()` +hands your payload to the `StateProvider` and answers `false`, leaving you to +show a screen. Both are supported; only the first is automatic. + +*Saving happens continuously, not at shutdown.* Every navigation marks the state +dirty and a checkpoint is written once per pass of the event loop, so by the time +the operating system suspends the app the work is already done. Don't look for a +place to save on exit; there isn't one worth using. Android blocks its own main +thread until your `stop()` returns, so an app that did its saving there would pay +for it on every suspend. Call `Continuity.checkpoint()` after changing something +your provider reports that no navigation touched. + +*A payload has to survive leaving the device.* It's written to disk, handed to an +operating system, and possibly delivered to a different device running a +different build of your app -- so it admits only `String`, `Integer`, `Long`, +`Double`, `Boolean`, and `List` and `Map` of those. Anything else is refused +where you produced it, with a message naming the key, rather than becoming a +value that stops arriving on the other end with nothing to say so. + +*Codename One runs no relay server.* Continuation between Apple devices is the +platform's; anything else -- iPhone to Android, two devices that are never in the +same room -- goes through a `StateRelay`, which is your endpoint. That isn't a +gap to be filled later. Deciding which saved states belong to the same *person* +is your account system's question, and a framework that answered it would be +guessing about your users. + +*A continuation isn't secure storage.* What you put in the payload crosses to +another device and is held by the operating system on the way. Tokens, keys and +anything you would not want restored on a device that merely shares an account +belong in `com.codename1.security.SecureStorage`, with the payload carrying at +most an identifier that means nothing on its own. + +=== Saving and restoring + +Two pieces. A `StateProvider` supplies the half the framework can't work out -- +the scroll position, the half-typed message, the record being edited -- and +installing one turns the framework on: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=provider,indent=0] +---- + +Then `start()` reads as "restore, or else begin": + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=start,indent=0] +---- + +`restore()` returns `true` when it put a screen up, so the caller knows not to +show its own. It returns `false` when there was nothing to restore *and* when the +state carried no routes -- the payload-only case above -- which is why the +fallback branch belongs there rather than behind a null check. + +`restoreState` runs before the restored screens are built, so a form the route +table is about to construct can read what the provider stashed while that form +is being built. + +Take a checkpoint by hand after a change no navigation followed: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=checkpoint,indent=0] +---- + +The title is what a receiving device may show the user before they accept, so it +should name the work rather than the screen -- `Draft to Dana`, not `Compose`. + +By default a saved state never expires, because an app the user opens after a +month should still come back where they left it. Where coming back is only +meaningful for a while -- a checkout, a booking hold, a queue position -- say so: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=maxAge,indent=0] +---- + +=== Continuing on another device + +Nothing extra is required for the Apple case. Every checkpoint advertises the +current state, and a device the user is holding is offered it by the system. What +you may want is to say so in the interface, which is what the capability query is +for: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=capability,indent=0] +---- + +An arriving state is restored automatically. When moving the user is a decision +your app should make -- they're midway through a payment, or the state belongs +to a different account than the one signed in here -- take it yourself. A +listener that returns `false` has consumed the state: nothing is restored and no +other listener is asked, which is what makes holding it and asking work: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=askFirst,indent=0] +---- + +A state this device published is never offered back to its own listener, and a +state already acted on is never acted on twice -- a continuation and a relay +routinely carry the same one. + +=== Reaching every other device + +A `StateRelay` is your endpoint, and `RestStateRelay` covers the common case: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=relay,indent=0] +---- + +Two requests against the one URL. A `POST` carries the state as a JSON body, +which you store against the signed-in user, replacing whatever you held for them. +A `GET` answers with the newest state you hold for that user, or an empty body +when you hold none. The JSON is a closed shape: your endpoint stores and returns +the document and never needs to look inside it. + +The token comes from `getToken()` rather than from the constructor because it's +read at every request, so a session that refreshes its token is followed with no +further code. + +A relay is written to when the app checkpoints and read only when something asks, +so ask when the app comes back to the foreground: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=pollOnResume,indent=0] +---- + +On Android that call is already made for you when the activity resumes. Making it +yourself as well is harmless -- a state already seen is ignored. + +Put `Continuity.clear()` on your logout path. The advertised activity outlives +your app's own screen, so an account's work would otherwise stay on offer to the +devices around it after the user signed out: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0] +---- + +=== The synced store + +`com.codename1.continuity.sync.SyncedStore` is the slow, patient half: a handful +of durable choices -- which theme, which sort order, which tutorial they already +dismissed -- kept in step across the devices one person is signed in to, without +those devices ever being near each other. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStore,indent=0] +---- + +Note the shape: a read always has a default, and a write reports whether it +happened. That isn't defensive style, it's the API being honest. The store is +empty on a device that has never synced, the user can switch the whole mechanism +off, and it exists on Apple platforms only -- so a synced value with a local +default behind it makes the design work on every platform. + +Changes made elsewhere arrive without values, on every platform that has such a +store at all, so re-read what your screen shows rather than assuming you know +which key moved: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStoreListener,indent=0] +---- + +This isn't storage. It's small, the platform decides when to sync it, and +nothing in it should be anything your app can't do without. + +=== Developing Without Hardware + +The simulator carries a simulated continuity platform, so all this works on +the desktop -- and the `Simulate -> Continuity` menu scripts the cases that are +otherwise reachable only with two devices in your hands. Every item is also +callable from a test with `CN.execute("continuity:itemN")`. + +*Continue Here (As Another Device)* hands whatever the app is currently +advertising straight back to it. That's the whole feature in one click. If it +does nothing, the app hasn't taken a checkpoint yet -- which is itself the +answer to the question of why nothing is being offered. + +The rest reproduce traps rather than the happy path: + +* *Continue A Route This Build Dropped.* A screen goes away in a rebuild and the +states already sitting on the user's other devices still name it. The restore +survives on the frames it can still build. +* *Continue With No Routes (Payload Only).* What an app that doesn't use +`@Route` produces. An app that assumed `restore()` always shows something finds +out here. +* *Continue Something From Yesterday.* Exercises `setMaxAge`, and the listener +that has to decide whether moving the user somewhere they were yesterday is a +courtesy or an ambush. +* *Change The Synced Store Elsewhere.* The notification carries no values, so an +app that re-reads only the key it assumed changed reads a stale one. +* *Make The Synced Store Unsupported* and *Make Continuation Unsupported.* What +every non-Apple platform reports. An app that put a required setting in the +synced store and never checked `isSupported()` loses it here, with no error, +exactly as it would on Android. + +=== Build Hints + +[options="header"] +|=== +| Hint | Default | What it does +| `ios.continuity.enabled` | `false` | Declares that this project hands work between devices. The build works this out from bytecode on its own; this exists because the Certificate Wizard and the signing preflight can't read bytecode and need to know whether an iCloud capability will be wanted. +| `ios.continuity.sync` | `true` | Set `false` to skip the iCloud key-value store entitlement. +|=== + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-001,indent=0] +---- + +Everything else is automatic. Referencing `com.codename1.continuity` compiles the +`NSUserActivity` handling into the iOS build and declares this app's activity +type in `NSUserActivityTypes`, which is what lets another device be offered the +work -- iOS continues an activity only when the app declared its type, so an app +that skipped this would publish states nobody is ever shown. Referencing +`com.codename1.continuity.sync` additionally asks for the +`com.apple.developer.ubiquity-kvstore-identifier` entitlement. Apps that touch +neither package get none of it, on any platform. Android needs nothing injected +at all: no permission, no manifest entry, no dependency. + +The activity type is your package name followed by `.continuity`, derived the +same way in the build and at runtime, so there's nothing to configure and +nothing to get out of step. `Continuity.getActivityType()` returns it, which is +the first thing to check when a continuation never arrives. + +That entitlement is the one part of this that can stop a build. Apple grants it +only through an App ID with the iCloud capability enabled, so a profile issued +before that was switched on matches your bundle id and authorizes none +of it -- and the build fails at codesigning, talking about an entitlement rather +than about the capability. Codename One checks the profile before sending the +build and warns, naming both ways out: enable iCloud on the App ID and regenerate +the profile, or drop the entitlement. + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-002,indent=0] +---- + +With that set, `SyncedStore.isSupported()` reports `false` at runtime and the +rest of the app is unaffected. Handing work to a nearby device needs no +entitlement and keeps working either way. + +WARNING: A restored state is restored on a device that has the app, not +necessarily on the device that saved it and not necessarily by the person who +did. Treat the payload as a description of *what screen to show*, never as proof +of who is looking at it: re-check the signed-in account after restoring, and put +nothing in a payload that would be a disclosure if it appeared on a family +member's iPad. This is also what makes `Continuity.clear()` on logout more than +housekeeping. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 1b61a722511..8bab7094511 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -149,6 +149,8 @@ include::Printing.asciidoc[] include::Deep-Links-Routing.asciidoc[] +include::State-Restoration-And-Continuity.asciidoc[] + include::App-Intents.asciidoc[] include::Document-Provider.asciidoc[] diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index ef39d94799a..fd251c01eae 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -455,6 +455,15 @@ "SurfacesTimelineLogicTest" ] }, + { + "id": "state-restoration-continuity", + "category": "System surfaces", + "name": "State restoration and continuity", + "description": "Saves and restores what the user was doing, and checks the parts that have to behave identically on every port: the codec both wire formats share, the payload rule that lets a state cross to another device, the checkpoint, and the routeless restore that hands its payload back and shows nothing.", + "tests": [ + "ContinuityStateTest" + ] + }, { "id": "document-provider", "category": "System surfaces", diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 29a3d695493..6e464a0d87a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -792,6 +792,30 @@ static void register(List h) { + "group, no plist keys -- leaving com.codename1.documents an inert no-op " + "at runtime.")); + h.add(new Hint("ios.continuity.enabled") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("false") + .platform("ios") + .doc("Declares that this project hands the user's work between their devices. " + + "The build detects a reference to com.codename1.continuity on its own, " + + "so this is redundant for the build itself; it exists because the " + + "Certificate Wizard and the signing preflight work without reading " + + "bytecode and need to know whether an iCloud capability will be " + + "wanted.")); + + h.add(new Hint("ios.continuity.sync") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Set false to skip the iCloud key-value store entitlement that a reference " + + "to com.codename1.continuity.sync would otherwise earn. Use it when the " + + "App ID has no iCloud capability and the app can live without a synced " + + "store: SyncedStore then reports itself unsupported at runtime instead " + + "of the build failing to sign. Handing work to a nearby device is " + + "unaffected -- that half needs no entitlement.")); + h.add(new Hint("ios.superfastBuild") .group(HintGroup.IOS) .type(HintType.BOOLEAN) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 19d5a4247ad..8daba0b4a45 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1304,6 +1304,21 @@ private java.util.Set foldInCallAndVpnLibraryUsage( // extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime. private boolean surfacesExtensionEnabled; + // Set when the app references com.codename1.continuity. Gates the CN1_USE_CONTINUITY native + // define and this app's entry in NSUserActivityTypes -- which is all continuation costs, + // there being no entitlement behind NSUserActivity handoff. + // + // Note what this does NOT gate: saving and restoring state on the device is pure Java over + // com.codename1.io.Storage and works in every build. Only the cross-device half is here. + private boolean usesContinuity; + + // Set when the app references com.codename1.continuity.sync -- deliberately narrower than + // usesContinuity, and for the reason usesHomeAccessoryData is narrower than usesSmartHome. + // The synced store is NSUbiquitousKeyValueStore, whose entitlement has to be granted on the + // App ID, so handing it to an app that only wanted to pass work to the tablet in the user's + // other hand would fail its codesigning for a capability it never asked for. + private boolean usesContinuitySync; + // Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native // define, the CN1Documents file provider extension and the app group that lets the two // processes meet. @@ -2630,6 +2645,20 @@ public void usesClass(String cls) { if (!usesDocuments && cls.indexOf("com/codename1/documents/") == 0) { usesDocuments = true; } + // State restoration and continuity (com.codename1.continuity.*). Gated on + // actual usage so the CN1_USE_CONTINUITY natives and the NSUserActivityTypes + // entry are only added for apps that hand work between devices. + if (!usesContinuity && cls.indexOf("com/codename1/continuity/") == 0) { + usesContinuity = true; + } + // The synced store, which is the only half that costs an entitlement. Its own + // package, so this prefix is a strict extension of the one above and both + // flags are set for an app that uses it -- which is correct: the store needs + // the native define too. + if (!usesContinuitySync + && cls.indexOf("com/codename1/continuity/sync/") == 0) { + usesContinuitySync = true; + } // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage // so WatchConnectivity.framework and the CN1_USE_WATCHCONNECTIVITY // natives are only added for apps that talk to their watch app. @@ -4061,6 +4090,19 @@ public void usesClassMethod(String cls, String method) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_DOCUMENTS", "#define CN1_USE_DOCUMENTS"); } + // com.codename1.continuity usage compiles the NSUserActivity / NSUbiquitousKeyValueStore + // glue (gated by CN1_USE_CONTINUITY so other builds carry no such symbols), and opens + // the continuity branch in the app delegate. The define lives in the shared + // CodenameOne_GLViewController.h so it reaches every continuity translation unit, + // mirroring CN1_USE_INTENTS. + // + // Not gated on the sync opt-out below: the store reports its own availability at + // runtime from whether the entitlement actually granted one, and the continuation half + // needs these symbols regardless. + if (usesContinuity) { + replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_CONTINUITY", "#define CN1_USE_CONTINUITY"); + } + // com.codename1.wearable usage compiles the WatchConnectivity glue (gated by // CN1_USE_WATCHCONNECTIVITY so other builds carry no WCSession symbols). The define // lives in the shared CodenameOne_GLViewController.h so it reaches every wearable @@ -5394,6 +5436,38 @@ public void usesClassMethod(String cls, String method) { + " false. Remove the hint to build the rest of the" + " app."); } + // The synced key/value store behind com.codename1.continuity.sync. + // + // Earned by the sync package alone, never by com.codename1.continuity. This + // entitlement has to be granted on the App ID before the app will sign at all, + // so giving it to an app that only hands work to a nearby device -- which costs + // nothing but a declared activity type -- would fail its codesigning for a + // capability it never asked for. Same reasoning as the HomeKit split above. + // + // The value is the two Xcode variables Apple documents for it rather than a + // literal, so it stays correct when the team or the bundle id changes, and so a + // build for a second team needs no edit here. + if (usesContinuitySync + && !"false".equals(request.getArg("ios.continuity.sync", "true"))) { + String kvStore = request.getArg("ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", null); + // A BLANK hint counts as absent, for the reason the VPN entitlement below + // spells out: buildNamespacedEntitlements skips an empty value entirely, so a + // project that set this to "" would suppress the generated entry and + // contribute nothing itself -- shipping an app whose SyncedStore silently + // stores nothing, which fails only at runtime on a device. + if (kvStore == null || kvStore.trim().length() == 0) { + request.putArgument("ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", + "$(TeamIdentifierPrefix)$(CFBundleIdentifier)"); + } + // An explicit non-blank value is left exactly as the project wrote it. Unlike + // the VPN entitlement there IS more than one legitimate value here -- an app + // sharing a store with a sibling app names that sibling's container -- so + // refusing anything but the default would break a configuration Apple + // supports. + } + // VPN configuration management. // // Both entitlements here are single-element arrays, which the @@ -11542,6 +11616,21 @@ static String withSpotlightContinuation(String inject, boolean usesIntents) { /// `IOSAppIntentsBuilder.publishesUserActivity` for why advertising the rest is not /// harmlessly generous. static String userActivityTypesKey(List> intents) { + return userActivityTypesKey(intents, null); + } + + /// The same key, carrying the continuity activity type alongside the intent ids. + /// + /// One key, not two. `NSUserActivityTypes` appears once in a property list or iOS reads the + /// file unpredictably, so the two features that contribute to it -- app intents and + /// continuity -- have to meet here rather than each emitting their own. An app that uses both + /// is the ordinary case, not a corner. + /// + /// #### Parameters + /// + /// - `intents`: the app's intent declarations, possibly empty + /// - `continuityType`: this app's continuity activity type, or null when it uses none + static String userActivityTypesKey(List> intents, String continuityType) { StringBuilder types = new StringBuilder(); for (Map intent : intents) { Object id = intent.get("id"); @@ -11549,6 +11638,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 @@ -11560,6 +11652,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 @@ -11582,6 +11686,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; } @@ -14119,19 +14227,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 d0b69c97942..a57651956d0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -518,6 +518,12 @@ private static int testTimeoutMs(BaseTest testClass) { // at all is also what makes the iOS extension target and the Android get // generated and compiled in the first place. new DocumentProviderPublishTest(), + // State restoration and continuity on the device VM: the codec both wire formats + // share, the payload rule, the checkpoint and the restore. Referencing + // com.codename1.continuity at all is also what makes the iOS build compile the + // NSUserActivity natives and declare this app's activity type in the plist. + new ContinuityStateTest(), + // App intents on the device VM: the generated registry, the coercion it wraps // every parameter in, and entity resolution behind an id. The declarations it // exercises are also what make the iOS Swift and the Android shortcut resources diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java new file mode 100644 index 00000000000..b41e5478932 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.StateCodec; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Saves and restores application state on the device VM, so CI runs what the build generates. +/// +/// Declaring this is part of the coverage. Without a reference to `com.codename1.continuity` +/// anywhere in the project the iOS builder leaves `CN1_USE_CONTINUITY` commented out, so the +/// `NSUserActivity` natives and the continuity branch in the app delegate are never compiled, +/// and this app's activity type never reaches `NSUserActivityTypes` for the plist to be checked. +/// Every mistake in that half -- an Apple API misused, a plist key Xcode will not take, a native +/// symbol whose mangled name does not match the Java declaration -- is invisible until something +/// references the package. +/// +/// The rest is the half that has no platform behind it and therefore has to behave identically +/// everywhere: the codec both wire formats share, the payload rule that makes them possible, the +/// checkpoint, and the dedup that stops one state being acted on twice. Assertion-only test, no +/// screenshot. +public class ContinuityStateTest extends BaseTest { + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean runTest() { + try { + // Support probes must never throw, whatever they answer. + boolean continuation = Continuity.isContinuationSupported(); + boolean synced = SyncedStore.isSupported(); + System.out.println("CN1SS:INFO:test=ContinuityStateTest continuation=" + continuation + + " syncedStore=" + synced + + " platform=" + Display.getInstance().getPlatformName()); + + // The activity type is derived from the package name on this side and written into + // NSUserActivityTypes by the build on the other. If the two ever disagree, iOS + // silently refuses to deliver anything -- so the shape is asserted where it is + // computed. + String activityType = Continuity.getActivityType(); + assertBool(activityType != null && activityType.endsWith(".continuity"), + "activity type ends with .continuity"); + + final Map restored = new HashMap(); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", "cn1ss draft"); + state.put("count", Integer.valueOf(3)); + return state; + } + + public void restoreState(Map payload) { + restored.putAll(payload); + } + }); + assertBool(Continuity.isEnabled(), "installing a provider enables the framework"); + + Continuity.setTitle("cn1ss continuity"); + Continuity.checkpoint(); + + AppState stored = Continuity.getRestorableState(); + assertBool(stored != null, "a checkpoint leaves a restorable state"); + assertEqual("cn1ss draft", stored.getPayload().get("draft"), "stored payload"); + assertBool(stored.getSequence() > 0, "a stored state carries a sequence"); + assertBool(stored.getDeviceId() != null && stored.getDeviceId().length() > 0, + "a stored state names the device that produced it"); + + // Restoring an app with no routes hands the payload back and shows nothing, which is + // what lets "restore, or else begin" work. Answering true here would make such an app + // skip its own first screen. + assertBool(!Continuity.restore(), "a routeless restore shows no form"); + assertEqual("cn1ss draft", restored.get("draft"), "the payload reached the provider"); + + // Both wire formats, on the device VM. The JSON one crosses the network to another + // device and the map one is handed to the operating system, and a millisecond + // timestamp is past the range a JSON number represents exactly -- which is why they + // are encoded as strings and why that is asserted rather than assumed. + AppState wire = new AppState() + .setRoutes(routes()) + .setPayload(payload()) + .setDeviceId("cn1ss-device") + .setSequence(4242L) + .setTimestamp(1763512345678L); + AppState viaJson = StateCodec.fromJson(StateCodec.toJson(wire)); + assertBool(viaJson != null, "a state survives the JSON form"); + assertEqual(1763512345678L, viaJson.getTimestamp(), "timestamp survives JSON exactly"); + assertEqual(4242L, viaJson.getSequence(), "sequence survives JSON exactly"); + assertEqual(2, viaJson.getRoutes().size(), "routes survive JSON"); + AppState viaMap = StateCodec.fromMap(StateCodec.toMap(wire)); + assertBool(viaMap != null, "a state survives the map form"); + assertEqual("cn1ss", viaMap.getPayload().get("name"), "payload survives the map form"); + + // The payload rule is enforced where the application can act on it, on every port. + boolean refused = false; + try { + Map bad = new HashMap(); + bad.put("when", new java.util.Date()); + new AppState().setPayload(bad); + } catch (IllegalArgumentException expected) { + refused = true; + } + assertBool(refused, "an unrepresentable payload value is refused"); + + // The synced store answers honestly on the ports that have none, and every call is + // safe there. This is the ordinary case for Android, the desktop and the browser. + assertEqual("byName", SyncedStore.get("cn1ss.sortOrder", "byName"), + "an absent synced value answers with the default"); + boolean wrote = SyncedStore.put("cn1ss.sortOrder", "byDate"); + assertEqual(synced, wrote, "a synced write succeeds exactly where a store exists"); + if (wrote) { + assertEqual("byDate", SyncedStore.get("cn1ss.sortOrder", "byName"), + "a synced value reads back"); + SyncedStore.remove("cn1ss.sortOrder"); + } + assertBool(SyncedStore.keys() != null, "the key list is never null"); + + // Clearing must be safe everywhere, including twice and including when the platform + // never advertised anything. + Continuity.clear(); + Continuity.clear(); + assertBool(Continuity.getRestorableState() == null, + "clearing forgets the stored state"); + + // The device runner waits for this before moving on. A test that returns true + // without it never reports DONE, and the suite fails the whole port with + // "timeout waiting for DONE stage=created" rather than naming the test. + done(); + return true; + } catch (Throwable t) { + t.printStackTrace(); + done(); + return false; + } + } + + private static List routes() { + List paths = new ArrayList(); + paths.add("/home"); + paths.add("/users/42"); + return paths; + } + + private static Map payload() { + Map nested = new HashMap(); + nested.put("street", "Sesame"); + List tags = new ArrayList(); + tags.add("a"); + tags.add(Integer.valueOf(2)); + tags.add(Boolean.TRUE); + Map payload = new HashMap(); + payload.put("name", "cn1ss"); + payload.put("address", nested); + payload.put("tags", tags); + return payload; + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-hints.md b/scripts/initializr/common/src/main/resources/skill/references/build-hints.md index 22e5b8b24f4..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 e7cf4650da3898e0da5f9a755daf3a0b7888e6ab 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 02/25] 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 8daba0b4a45..60fdee3a2b4 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 @@ -2648,6 +2648,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 4e99e172bde71ca6b0e277ef71f66045eef45ce1 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 03/25] 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 60fdee3a2b4..4ee6d669f8c 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 @@ -11662,6 +11662,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); } @@ -14251,13 +14298,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 d5f50f6803f073aa43bc8bea35a65a7d52daf1ad 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 04/25] 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 4ee6d669f8c..d7f80f79f64 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 @@ -11662,6 +11662,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 @@ -11679,7 +11708,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; } @@ -11726,8 +11755,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 @@ -11712,22 +11753,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); @@ -11761,11 +11788,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 f15b09c59e579933c6e54c1da33ed2bc1c674903 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 10/25] 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 613ad469d2198c72d31f41c38263cb23ab419fbd 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 11/25] 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 66e97f4b5a0..21a1edb3b19 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 @@ -11670,6 +11670,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)) { @@ -11749,7 +11821,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; } @@ -11787,7 +11859,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 @@ -14372,7 +14444,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 7b4a6a3bef3658fd6c5b5aa504e901cfc4095486 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 12/25] 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); @@ -11734,9 +11742,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 a6d2f19106847ae0d9c833a55cfa95f0438d54cb 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 13/25] 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 7b326c54d0b..a4ab31f28d5 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 @@ -11821,15 +11821,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); } @@ -11902,6 +11920,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"); @@ -11909,12 +11931,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 b0bf5cd3a2956cd16de1c6664514d35019884e8f 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 14/25] 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 f3678d1614a144aeabb958d9f9d646db38613dbd 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 15/25] 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 f38e58fd1447357990cda12e8220bb649ace7daa 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 16/25] 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 429e53d3becb6946325b30419ee28ab9791ec83f 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 17/25] 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 c0169123cf97dd598d1deea3f481e4d2ac0507f1 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 18/25] 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 ae8f3e0803e517520edd5f4c6cfa2f1029510750 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 19/25] 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 769107f3526..c5225899df2 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20116,11 +20116,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]) { @@ -20137,6 +20139,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 288bebe3a16ea92ce3b0b7e6e782fd1d111075c5 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 20/25] 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 33dab9fe1a76754f858d217387ea6ada5dc2f46a 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 21/25] 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 a4ab31f28d5..819ec4e0e88 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -10960,7 +10960,11 @@ private static String resolveSettingsInValue(String value, Map a private static final int MAX_SETTING_EXPANSIONS = 16; /// One build setting, in either of the two spellings Xcode accepts for a reference. - private static String replaceBuildSetting(String path, String name, String value) { + /// + /// Package-visible rather than private because MacNativeBuilder needs the SAME answer: the + /// Catalyst entitlement has to materialize the iOS bundle id, and hand-listing the spellings + /// there was how "$(CFBundleIdentifier)" got handled while "${CFBundleIdentifier}" did not. + static String replaceBuildSetting(String path, String name, String value) { String out = path.replace("$(" + name + ")", value).replace("${" + name + "}", value); return applyModifiers(out, name, value); } 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 7ca321ea077e41b83004c29fa83a49e85d325b28 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 22/25] 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 b90e498d0e5bc0b8e269190b9f70615226cac952 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 23/25] 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 79347ac828633ebb123d883dee23e88fe13a927a 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 24/25] 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 db05fb1da2391a7da6b6bbba731fcca34ea2f87e 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 25/25] 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(); + } } }