Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1f2e71
State restoration and continuity across devices
shai-almog Sep 2, 2026
e7cf465
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
4e99e17
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
d5f50f6
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
7b65267
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
2e3a87b
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
c7c1cc9
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
835d793
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
488eb5f
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
f15b09c
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
613ad46
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
7b4a6a3
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
a6d2f19
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
b0bf5cd
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
f3678d1
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
f38e58f
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
429e53d
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
c016912
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
ae8f3e0
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
288bebe
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
33dab9f
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
7ca321e
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
b90e498
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
79347ac
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Externalizable;
import com.codename1.io.Util;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
/// `StateProvider` chose to add.
///
/// The same value serves three purposes, which is why it carries more than the two halves above.
/// It is written to storage so the app can come back after its process dies; it is advertised to
/// the user's other devices so one of them can continue the work; and it travels through a
/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
/// -- from one worth acting on.
///
/// #### The routes
///
/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
/// Restoring it re-runs each path through the route table, which is why an app that navigates with
/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
/// from the payload instead.
///
/// #### The payload
///
/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
/// and delivered to a *different device running a possibly different build of your app*, so it is
/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
/// be traced back here.
public final class AppState implements Externalizable {
/// The `Util.register` id. Changing it orphans every state already on a device.
static final String OBJECT_ID = "CN1AppState";

private List<String> routes = new ArrayList<String>();
private Map<String, Object> payload = new HashMap<String, Object>();
private String deviceId = "";
private String title;
private long sequence;
private long timestamp;

/// The navigation stack as route paths, oldest first. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the route paths
public List<String> 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<String> r) {
routes = new ArrayList<String>();
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);
Comment thread
shai-almog marked this conversation as resolved.
}
index++;
}
}
return this;
}

/// The application payload. Never null, possibly empty.
///
/// #### Returns
///
/// an unmodifiable view of the payload
public Map<String, Object> 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<String, Object> p) {
StateCodec.requireRepresentable(p);
payload = deepCopy(p);
return this;
}

/// Replaces the payload without validating it. Used only for a payload that arrived from
/// another device: it was already validated where it was produced, and refusing it here would
/// turn a remote mistake into an exception on this device at a moment the user cannot connect
/// to anything they did.
///
/// #### Parameters
///
/// - `p`: the payload; null is treated as empty
void setPayloadUnchecked(Map<String, Object> 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<String, Object> deepCopy(Map<String, Object> p) {
Map<String, Object> out = new HashMap<String, Object>();
if (p == null) {
return out;
}
for (Map.Entry<String, Object> 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<Object> out = new ArrayList<Object>();
for (Object element : in) {
out.add(copyValue(element));
}
return out;
}
if (value instanceof Map) {
Map<?, ?> in = (Map<?, ?>) value;
Map<String, Object> out = new HashMap<String, Object>();
for (Map.Entry<?, ?> e : in.entrySet()) {
if (e.getKey() instanceof String) {
out.put((String) e.getKey(), copyValue(e.getValue()));
}
}
return out;
}
return value;
}

/// The device this state was produced on. Used to drop a state's own echo when it comes back
/// through a relay. Never null.
///
/// #### Returns
///
/// the originating device id
public String getDeviceId() {
return deviceId;
}

/// Sets the originating device id.
///
/// #### Parameters
///
/// - `id`: the id; null is treated as the empty string
///
/// #### Returns
///
/// this state, for chaining
public AppState setDeviceId(String id) {
if (id != null) {
// Framework-generated in every path we own, and validated anyway: a port supplying its
// own id writes it through the same writeUTF as everything else here.
StateCodec.requireWritable(id, "deviceId");
}
deviceId = id == null ? "" : id;
return this;
}

/// A human readable label for what the user is doing, which a receiving device may show
/// before they accept the continuation. Null when the app did not set one.
///
/// #### Returns
///
/// the title, or null
public String getTitle() {
return title;
}

/// Sets the human readable label.
///
/// #### Parameters
///
/// - `t`: the title, or null for none
///
/// #### Returns
///
/// this state, for chaining
public AppState setTitle(String t) {
if (t != null) {
// Application-supplied, so this is the one of the three most likely to be long.
StateCodec.requireWritable(t, "title");
}
title = t;
return this;
}

/// A counter that increases with every state this device publishes. Together with the device
/// id it identifies a state exactly, which is how a receiver recognizes one it has already
/// acted on -- two states can share a timestamp, because clocks are coarse.
///
/// #### Returns
///
/// the sequence number
public long getSequence() {
return sequence;
}

/// Sets the sequence number.
///
/// #### Parameters
///
/// - `s`: the sequence number
///
/// #### Returns
///
/// this state, for chaining
public AppState setSequence(long s) {
sequence = s;
return this;
}

/// When this state was produced, as milliseconds since the epoch on the producing device.
///
/// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
/// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
/// correction -- both within one session.
///
/// #### Returns
///
/// the timestamp
public long getTimestamp() {
return timestamp;
}

/// Sets the production timestamp.
///
/// #### Parameters
///
/// - `t`: milliseconds since the epoch
///
/// #### Returns
///
/// this state, for chaining
public AppState setTimestamp(long t) {
timestamp = t;
return this;
}

/// True when there is nothing here worth restoring or sending.
///
/// #### Returns
///
/// true when both the routes and the payload are empty
public boolean isEmpty() {
return routes.isEmpty() && payload.isEmpty();
}

@Override
public String toString() {
return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ ", device=" + deviceId + ", seq=" + sequence + "}";
}

// ------------------------------------------------------------------
// Externalizable -- the on-device format
// ------------------------------------------------------------------

@Override
public int getVersion() {
return 1;
}

@Override
public String getObjectId() {
return OBJECT_ID;
}

@Override
public void externalize(DataOutputStream out) throws IOException {
Util.writeUTF(deviceId, out);
Util.writeUTF(title, out);
out.writeLong(sequence);
out.writeLong(timestamp);
out.writeInt(routes.size());
for (String path : routes) {
Util.writeUTF(path, out);
}
// The payload goes through the framework's own object writer rather than a hand-rolled
// encoding: it already knows every type requireRepresentable admits, including nested
// lists and maps, and it is the same writer Storage uses for everything else.
Util.writeObject(payload, out);
Comment thread
shai-almog marked this conversation as resolved.
}

@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<String>();
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<String, Object>();
if (p instanceof Map) {
Map<?, ?> read = (Map<?, ?>) p;
for (Map.Entry<?, ?> entry : read.entrySet()) {
if (entry.getKey() instanceof String) {
payload.put((String) entry.getKey(), entry.getValue());
}
}
}
}
}
Loading
Loading