Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
140 commits
Select commit Hold shift + click to select a range
d64c288
State restoration and continuity across devices
shai-almog Sep 2, 2026
ae68340
Record why continuity needs no cn1lib scan, at the line that invites one
shai-almog Sep 2, 2026
a19eaa5
Address the continuity review: wire format, ordering, threading, life…
shai-almog Sep 2, 2026
24614c1
Fix the CLDC11 break, and merge into the live activity array
shai-almog Sep 2, 2026
f8d3855
Close the relay ordering window, cancel queued work on logout, stop p…
shai-almog Sep 2, 2026
e5a9652
Drop superseded inbound states, and refuse documents that carry no state
shai-almog Sep 2, 2026
e828b5a
Guard relay polls at logout, restart the publisher, and really keep a…
shai-almog Sep 2, 2026
d447333
Define "the key's value" once, guard lastSeen on clear, and match the…
shai-almog Sep 2, 2026
c4e7835
Continuity: invalidate queued deliveries on disable, end a plist key …
shai-almog Sep 2, 2026
1b23a36
Continuity: stop a restore republishing itself, serialize polls, rech…
shai-almog Sep 2, 2026
09d100b
Continuity: drain the publisher across an era change, resolve the sto…
shai-almog Sep 2, 2026
cbc8921
Continuity: one lock for all state, and five review fixes
shai-almog Sep 2, 2026
51d0eb7
Continuity: sweep the three defect classes rather than the reported i…
shai-almog Sep 2, 2026
8170718
Continuity: rebind coalesced polls, publish `enabled` last, reach the…
shai-almog Sep 2, 2026
1951b9c
Continuity: survive a restart, honour a reconnect, capture on the EDT
shai-almog Sep 2, 2026
b4ba67e
Continuity: keep Catalyst on the iOS container, abandon a cleared che…
shai-almog Sep 2, 2026
97b23da
Continuity: carry the relay era into delivery, hold a declined activi…
shai-almog Sep 2, 2026
dea0b0c
Continuity: serialize checkpoint side effects with clear, and fix two…
shai-almog Sep 2, 2026
1478eab
Continuity: cancel timed-out EDT work, serialize disable, persist the…
shai-almog Sep 2, 2026
5d02435
Continuity: keep a parked state until the restore succeeds, serialize…
shai-almog Sep 2, 2026
3d4d3b5
Continuity: serialize dispatch with clear, stop marking parked states…
shai-almog Sep 2, 2026
4b64b63
Continuity: revalidate the era under the lock, wait out started EDT w…
shai-almog Sep 2, 2026
9795de2
Continuity: carry the era through the cold-launch park, acknowledge p…
shai-almog Sep 2, 2026
f59c3f1
Continuity: defer a checkpoint's publish during a poll, guard the lis…
shai-almog Sep 2, 2026
31d60be
SyncedStore: guard the change-listener registry
shai-almog Sep 2, 2026
b12212f
Continuity: retry a failed store resolution, and clear only the state…
shai-almog Sep 3, 2026
d56d9ee
Continuity: validate the generation across a restore, a poll and a pr…
shai-almog Sep 3, 2026
f761172
Continuity: bind every arrival to the generation, and report an EDT f…
shai-almog Sep 3, 2026
b60dca3
IOSContinuityCallbacks: hold a continuation the installed callback de…
shai-almog Sep 3, 2026
2445585
Continuity: drop the locking and run on the EDT like the rest of the …
shai-almog Sep 3, 2026
2598337
Continuity: stop publishing after disable(), and mark a route-less re…
shai-almog Sep 3, 2026
b39549c
Continuity: store an applied state before acknowledging it, and obser…
shai-almog Sep 3, 2026
b8bc6d5
Continuity: only do durable bookkeeping after a confirmed durable write
shai-almog Sep 3, 2026
b19fc67
Continuity: order the relay pair in both directions, and keep the che…
shai-almog Sep 3, 2026
a72662e
Continuity: one owner for the durable mark, and a cap where entries g…
shai-almog Sep 3, 2026
ae7fe02
Developer guide: bank the code samples the restore chapters put back
shai-almog Sep 3, 2026
ce4c065
Continuity: tell "nothing to do" apart from "tried and failed"
shai-almog Sep 3, 2026
e511807
Continuity: evict high-water marks by recency, not by sequence
shai-almog Sep 3, 2026
613a609
Continuity: persist only completed marks, and escape the ids that car…
shai-almog Sep 3, 2026
9149c56
Continuity: logout forgets the continuation label
shai-almog Sep 3, 2026
ab3b3e3
Continuity: stale routes do not discard a payload that applied
shai-almog Sep 3, 2026
7d385ff
Continuity: four fixes -- parked expiry, failed capture, empty arriva…
shai-almog Sep 3, 2026
ba5b42a
Continuity: setBridge installs the callback through refreshBridge
shai-almog Sep 3, 2026
f91c31b
Continuity: verify the sequence write, separate the simulator origins…
shai-almog Sep 3, 2026
558c49c
Continuity: durable values go through a write that can fail, and a pa…
shai-almog Sep 3, 2026
63d767d
Continuity: a sequence that is not durable is stored but never published
shai-almog Sep 3, 2026
79250e5
Continuity: release the parked slot on every path that ends an arrival
shai-almog Sep 3, 2026
84f9532
LocalCallTest: the timer check asserts what its message claims
shai-almog Sep 3, 2026
21ba45a
Continuity: every reader of the two mark maps handles them disagreeing
shai-almog Sep 3, 2026
29b0c08
Continuity: a tombstone ends work still parked from the same origin
shai-almog Sep 3, 2026
a3889dd
Continuity: a superseded relay worker never reaches the network
shai-almog Sep 3, 2026
02a882e
Continuity: capture() refuses a state whose sequence never reached st…
shai-almog Sep 3, 2026
09d9d98
Continuity: the marks stay writable, and a type with nowhere to go fa…
shai-almog Sep 4, 2026
a81c1f0
iOS: a failed synchronize no longer makes the local synced store unus…
shai-almog Sep 4, 2026
190a223
Continuity: three silent losses at the relay and logout boundaries
shai-almog Sep 4, 2026
243014e
Continuity: three arrivals that ended without their bookkeeping
shai-almog Sep 4, 2026
ab6d0ea
Continuity: four more silent losses, one of them mine from last round
shai-almog Sep 4, 2026
0bc0e30
Continuity: the synced store finally reaches the platform, and two fa…
shai-almog Sep 4, 2026
e66b57b
Continuity: a failed read holds until one succeeds, and a newer state…
shai-almog Sep 4, 2026
8f7ba45
Continuity: a local write reports the truth, and an acknowledgement o…
shai-almog Sep 4, 2026
855a2ff
Continuity: the simulated synced store stops confirming writes from i…
shai-almog Sep 4, 2026
3b723b2
Continuity: a restore withdraws the activity it made stale, not just …
shai-almog Sep 4, 2026
60242ba
Continuity: the recovery read stops overlapping the read it recovers …
shai-almog Sep 4, 2026
a5e5000
Continuity: an arrival with no origin is refused instead of admitted
shai-almog Sep 4, 2026
c4e447a
RestStateRelay: background requests stop putting error dialogs in fro…
shai-almog Sep 4, 2026
ff23d28
Continuity: the unsupported-store simulation disables the store, not …
shai-almog Sep 4, 2026
008b5e2
Continuity: a listener that ends the session stops the dispatch it is…
shai-almog Sep 4, 2026
10a7f26
Continuity: logout deletes the delivery marks instead of overwriting …
shai-almog Sep 4, 2026
907c6f2
Continuity: refuse redirects on authenticated relay requests, and sto…
shai-almog Sep 4, 2026
e8c2f72
Continuity: scope the redirect guarantee honestly, and undo two defec…
shai-almog Sep 4, 2026
913275f
Continuity: a continuation arriving before enable() is declined, not …
shai-almog Sep 4, 2026
ffd914a
Continuity: the eviction order of the delivery marks survives a restart
shai-almog Sep 4, 2026
591fe00
Continuity: logout owes nothing afterwards, and every application cal…
shai-almog Sep 4, 2026
ec5fa36
Continuity: a route that logs out while being rebuilt stops the restore
shai-almog Sep 4, 2026
0d7c971
Continuity: the fetch worker confirms its session too, and a per-requ…
shai-almog Sep 4, 2026
6e3ee63
Continuity: a truncated relay document is a failed read, not an empty…
shai-almog Sep 4, 2026
b8c26cd
Continuity: validate relay JSON properly, hold the publisher through …
shai-almog Sep 4, 2026
ead2575
Continuity: an aborted restore takes the screen back, not just the hi…
shai-almog Sep 4, 2026
98a4ad2
Continuity: Navigation's rollback takes the screen back, and a write …
shai-almog Sep 4, 2026
3a7f52b
Continuity: a navigation whose own show() ended the session does not …
shai-almog Sep 4, 2026
b76b7a3
Continuity: notify on the stack change itself, and refuse relay field…
shai-almog Sep 4, 2026
41974dd
Continuity: guard both exits of the save callback, keep relay nulls v…
shai-almog Sep 4, 2026
dda20a5
Validate route elements, roll back a failed navigation, isolate the d…
shai-almog Sep 4, 2026
a29c14b
Bind a relay call to its session, bound the sequence, count every KVS…
shai-almog Sep 4, 2026
a17fe2a
Do not resurrect a cleared stack, close the 2^63 alias, keep an unrea…
shai-almog Sep 4, 2026
40cf4c7
Ask the EDT for the window, and drop an arrival that came after disab…
shai-almog Sep 4, 2026
5a40605
Reach the lifecycle guard from the catch too, and verify a delete bef…
shai-almog Sep 4, 2026
3d04f18
Refuse a raw control character, bind the queued dispatch, stop listin…
shai-almog Sep 4, 2026
dbed9f5
Keep the stack agreeing with the display, and validate the other way in
shai-almog Sep 4, 2026
a39c273
Refuse an oversized title at the call, and keep a thrown route rebuil…
shai-almog Sep 4, 2026
d4d0ecf
Leave the screen a logout callback chose, and refuse an encoding this…
shai-almog Sep 4, 2026
5b5b6bf
Stop a rebuild whose session ended, commit the routes that were kept,…
shai-almog Sep 4, 2026
89753cc
Close the route-factory case in the undo, and enforce the per-key iCl…
shai-almog Sep 4, 2026
a136cc4
Refuse an empty route string, and make the payload view unmodifiable …
shai-almog Sep 4, 2026
b0e42b2
Refuse a state held across a logout, and let a standalone relay send
shai-almog Sep 4, 2026
5b9d0e5
Check an arriving payload like a local one, and keep key case in the …
shai-almog Sep 4, 2026
1717b7d
Bind the arrival's first hop to its session, and refuse a stale ackno…
shai-almog Sep 4, 2026
3c1813f
Let a throwing route factory fail the restore, and escape what Window…
shai-almog Sep 4, 2026
bf5f57f
Escape store names by whitelist, and drop an inbound title too long t…
shai-almog Sep 4, 2026
62f5393
Fix the CLDC11 break, install one callback per bridge, keep untagged …
shai-almog Sep 5, 2026
c09cf58
Decide a callback on the thread that owns the state, refuse an unstor…
shai-almog Sep 5, 2026
97f3cb3
Say which statement wedges the tvOS suite
shai-almog Sep 5, 2026
3b742d1
Mint the origin id without standing up a web view
shai-almog Sep 5, 2026
ac35d9a
Undo only the restore's own screen, drain on clear, and stop claiming…
shai-almog Sep 5, 2026
315ccc9
Separate installing the seam from asking a port for what it held
shai-almog Sep 5, 2026
a94ff07
Configure the iOS continuation parser like the codec's
shai-almog Sep 5, 2026
06f9ba6
Bind an arrival at the instant it arrives, and hold a pre-enable one …
shai-almog Sep 5, 2026
6e35d24
Drain the held arrival through admission, not past it
shai-almog Sep 5, 2026
c383670
Treat a relay document with no origin as a failed read
shai-almog Sep 5, 2026
217be7b
Read an unrecognised relay object as a failed read, not an empty relay
shai-almog Sep 5, 2026
d667acb
Checkpoint a redirect made during a rebuild, and honour an explicit s…
shai-almog Sep 5, 2026
1f1aaf2
Drop the arrival this class is holding when disable() comes before an…
shai-almog Sep 5, 2026
8b44659
Hoist the disable() clear above the branch, for PMD and for clarity
shai-almog Sep 5, 2026
285561b
Read the arriving document before telling the port it was taken
shai-almog Sep 5, 2026
a3878e3
Gate the restore's stack rollback the way its display rollback is gated
shai-almog Sep 5, 2026
a0c9012
Apply the filtered routes where they are filtered, not one exit later
shai-almog Sep 5, 2026
b07b136
Let an offer replaced by another device come back
shai-almog Sep 5, 2026
4a3177d
Put every offer through the one place that does the bookkeeping
shai-almog Sep 5, 2026
a25f362
Keep a local checkpoint whose restore failed on offer
shai-almog Sep 5, 2026
65d0414
Recheck expiry where the application hands a state back, and let a fa…
shai-almog Sep 5, 2026
dd5abcb
Stop the rebuild at the redirect, and settle the arrival that caused it
shai-almog Sep 5, 2026
9b124a5
Give supersession a direction, and clear only the stack the restore i…
shai-almog Sep 5, 2026
b5aa480
Shelve a displaced arrival instead of betting on a redelivery
shai-almog Sep 5, 2026
3de7bc2
Give the JavaScript VideoIODecodedFrames baseline the tolerance every…
shai-almog Sep 5, 2026
9902e3c
Read an injected plist value the way Foundation does, and refuse a du…
shai-almog Sep 5, 2026
efb5862
Bound the cold-launch window at both ends, not just the wait
shai-almog Sep 5, 2026
275ffdb
Drain every held arrival at enable(), and write down the ordering cle…
shai-almog Sep 5, 2026
25e7dd2
Pair the logout, refuse a negative timestamp, and compare against the…
shai-almog Sep 5, 2026
9a63401
Read a string declaration structurally, refuse a duplicated continuit…
shai-almog Sep 5, 2026
d62e419
Release the publication hold from the shelf, refuse an intent that cl…
shai-almog Sep 5, 2026
b1574be
Drain both holders on the cold-launch path, keep pre-restore history,…
shai-almog Sep 5, 2026
44e73e7
Read the relay when a session begins
shai-almog Sep 5, 2026
7aa46c3
Copy what the bridge says it copies, compare the granted iCloud conta…
shai-almog Sep 5, 2026
6eeb9c8
Read a newly installed relay once, not twice
shai-almog Sep 5, 2026
d54b770
End both drains when the session does, and stop a negative timestamp …
shai-almog Sep 5, 2026
094a52b
Record why put() does not consult the sync entitlement
shai-almog Sep 5, 2026
764cf9b
Give the off period a generation of its own
shai-almog Sep 5, 2026
4ba4e50
Recognize the brace spelling of an Xcode variable in the container check
shai-almog Sep 5, 2026
6919801
Stop holding the signed-out account's screen, and close the callback-…
shai-almog Sep 5, 2026
36fece3
Walk arrays and lists with foreach, and say why the callback fields s…
shai-almog Sep 5, 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
473 changes: 473 additions & 0 deletions CodenameOne/src/com/codename1/continuity/AppState.java

Large diffs are not rendered by default.

4,152 changes: 4,152 additions & 0 deletions CodenameOne/src/com/codename1/continuity/Continuity.java

Large diffs are not rendered by default.

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

/// Notified when a state arrives from somewhere other than this device's own storage: one of the
/// user's other devices handed off what they were doing, or a `StateRelay` produced something
/// newer than what is here.
///
/// Registered with `Continuity.addContinuationListener(ContinuityListener)`. Called on the event
/// dispatch thread, and never for this device's own echo.
public interface ContinuityListener {
/// A state arrived. Return true to let the framework restore it, false to ignore it.
///
/// Returning false is the hook for the decisions only the app can make -- that the user is
/// midway through a payment and must not be moved, that the state is older than what is on
/// screen, that it belongs to a different account than the one signed in here. A listener
/// that returns false has consumed the state: nothing is restored and no other listener is
/// asked.
///
/// #### A false you will not come back from must acknowledge
///
/// False keeps the state. It has to: the ordinary reason to return false is that you are
/// about to ask the user, and the arrival's only other copy may be on the relay -- so the
/// framework holds it, and holds this device's own checkpoints off the relay behind it, until
/// you say what happened. `Continuity.restore(AppState)` says accepted;
/// `Continuity.acknowledge(AppState)` says finished with.
///
/// So a false that REJECTS -- the wrong account, older than the screen, anything you will
/// never restore -- must call `Continuity.acknowledge(AppState)`, or that hold never ends:
/// `Continuity.getRestorableState()` goes on offering the state you rejected, and this device
/// stops publishing to the relay for the rest of the process. The framework cannot tell a
/// rejection from a prompt that has not been answered yet, and guessing wrong in the other
/// direction loses work the user was about to accept.
///
/// Doing the work yourself and returning false is a supported pattern, and is how an app
/// prompts before jumping: keep the state, return false, and call
/// `Continuity.restore(AppState)` when the user accepts.
///
/// If you handle it yourself and never call `restore`, call
/// `Continuity.acknowledge(AppState)` instead. Restoring records that the state was acted on
/// so it is not offered again after a relaunch; handling it silently does not, and without
/// the acknowledgement the relay's unchanged document is accepted on the next launch and your
/// side effects run a second time. It is not inferred from the false return, because false
/// also means "I am going to prompt" -- and marking that handled before the user answers
/// would lose the state if the process died first.
///
/// #### Parameters
///
/// - `state`: the state that arrived
///
/// #### Returns
///
/// true to restore it now
boolean stateReceived(AppState state);
}
252 changes: 252 additions & 0 deletions CodenameOne/src/com/codename1/continuity/RestStateRelay.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.continuity;

import com.codename1.io.Log;
import com.codename1.ui.Display;
import com.codename1.io.rest.ErrorCodeHandler;
import com.codename1.io.rest.RequestBuilder;
import com.codename1.io.rest.Response;
import com.codename1.io.rest.Rest;

import java.io.IOException;

/// A `StateRelay` over your own HTTPS endpoint, which is all most applications need.
///
/// ```java
/// Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") {
/// protected String getToken() {
/// return session.getAccessToken();
/// }
/// });
/// ```
///
/// #### The contract
///
/// Two requests against the one URL you supply:
///
/// - `POST` with the state as a JSON body and `Content-Type: application/json`. Store it against
/// the signed-in user, replacing whatever you held for them. Any 2xx means stored.
/// - `GET`, answering with the newest state you hold for that user as the same JSON, or an empty
/// body when you hold none. A 404 also means none.
///
/// The JSON is exactly what `StateCodec.toJson(AppState)` produces, and it is a closed shape: your
/// endpoint stores and returns the document, and never needs to look inside it.
///
/// #### Identity is yours
///
/// Which states belong to the same person is the one question the framework cannot answer, which
/// is why the token comes from `getToken()` rather than from a constructor: it is read at each
/// request, so a session that refreshes its token is followed automatically. Return null for an
/// endpoint that identifies the user some other way -- a cookie, mutual TLS -- and the header is
/// simply omitted.
///
/// #### Threading
///
/// Both methods are called from a background thread and block, which is what the framework
/// expects of a relay. `getToken()` is called on that same thread, so it must not wait on the
/// event dispatch thread.
public class RestStateRelay implements StateRelay {
private final String url;

/// Creates a relay against an HTTPS endpoint.
///
/// #### Parameters
///
/// - `url`: the endpoint, which must be HTTPS
///
/// #### Throws
///
/// - `IllegalArgumentException`: when the URL is null, empty or not HTTPS
public RestStateRelay(String url) {
if (url == null || url.length() == 0) {
throw new IllegalArgumentException("A continuity relay needs an endpoint URL.");
}
if (url.length() < 8 || !"https://".equals(url.substring(0, 8).toLowerCase())) {
// Refused rather than passed on. The bearer token goes out on every request and the
// payload is a description of what the user is doing on their other device; an
// "http://" typo would put both on the network in the clear wherever a cleartext
// policy still allows it.
throw new IllegalArgumentException("A continuity relay endpoint must be HTTPS; got \""
+ url + "\".");
}
this.url = url;
}

/// The endpoint this relay talks to.
///
/// #### Returns
///
/// the URL
public String getUrl() {
return url;
}

/// The bearer token to present, read once per request. The default returns null, which sends
/// no `Authorization` header.
///
/// #### Returns
///
/// the token, or null for none
///
/// #### Changing accounts
///
/// Install a NEW relay for the new account -- `Continuity.setRelay(StateRelay)` -- rather than
/// returning a different account's token from the same object. A publish that was authorised
/// for the previous account can still be between the framework's last check and this read
/// when the switch happens, and the framework cannot bind a token it is not allowed to read.
/// What it can recognise is an object that is no longer installed, which it then refuses; an
/// object that quietly starts answering for someone else looks identical to one that
/// refreshed its own session.
protected String getToken() {
return null;
}

@Override
public void publish(AppState state) throws IOException {
Response<String> response = auth(Rest.post(url).jsonContent()
.body(StateCodec.toJson(state))).getAsString();
Comment thread
shai-almog marked this conversation as resolved.
int code = response.getResponseCode();
if (code < 200 || code > 299) {
throw new IOException("The continuity relay refused the state: HTTP " + code
+ (response.getResponseErrorMessage() == null ? ""
: " " + response.getResponseErrorMessage()));
}
}

@Override
public AppState fetch() throws IOException {
Response<String> response = auth(Rest.get(url).jsonContent()).getAsString();
Comment thread
shai-almog marked this conversation as resolved.
int code = response.getResponseCode();
if (code == 404 || code == 204) {
// Not an error. An endpoint that holds nothing for this user yet is the ordinary
// state of affairs on a first run, and throwing here would log a failure on every
// launch until the user's second device wrote something.
return null;
}
if (code < 200 || code > 299) {
throw new IOException("The continuity relay refused to answer: HTTP " + code
+ (response.getResponseErrorMessage() == null ? ""
: " " + response.getResponseErrorMessage()));
}
return StateCodec.fromJson(response.getResponseData());
}

/// Adds the bearer token, refusing outright if this relay is no longer the installed one.
///
/// The refusal is HERE, immediately before the token is read, because that is what makes it
/// worth anything. A worker that was started for one account and reaches the network after
/// the user has signed out and back in would otherwise send the first account's state
/// authenticated as the second: getToken() is read at each request, by design, so the same
/// relay object answers with whoever is signed in NOW.
///
/// Continuity stops such a worker before it calls a relay at all. This is the second line for
/// the gap that check cannot cover -- it runs on the event thread, and the worker is not it.
/// Throwing rather than skipping quietly, so the framework records the publish as failed and
/// keeps owing it, and the state is republished once a relay is installed again.
///
/// It asks whether this relay may SEND, not merely whether it is installed. A logout keeps
/// the same relay object in place on purpose, so identity alone said yes to a worker whose
/// account had signed out between its preflight and this line.
private RequestBuilder auth(RequestBuilder b) throws IOException {
if (!Continuity.mayRelaySend(this)) {
throw new IOException("This relay may not send: Continuity.setRelay() replaced it, "
+ "or Continuity.clear() ended the session this request belongs to. Refusing "
+ "the request rather than sending one account's state under another "
+ "account's credentials.");
}
// SILENT, because these are housekeeping requests the user never asked for. A request
// builder sets failSilently only when an error-code handler is registered, and without it
// ConnectionRequest puts a Retry/Cancel dialog in front of the user for both a failure
// response and a connection exception. The 404 below is the DOCUMENTED answer for a relay
// that holds nothing yet, so a correctly implemented endpoint showed every user an error
// dialog on first run -- for the ordinary case, before this class could read the code and
// call it an empty relay.
//
// The handler itself does nothing on purpose: getAsString() builds its Response from the
// request's own code and body rather than from these callbacks, so publish() and fetch()
// still see 404, 204 and everything else exactly as before.
// NO REDIRECTS, because this request carries a bearer token. A redirect is followed
// with the same headers, so a 307 would hand the token and the state to whatever host
// the response names -- an `http://` one included, silently undoing the HTTPS the
// constructor insists on. A 302 or 303 is not safer, only different: it turns the POST
// into a GET, and the 2xx that follows makes publish() report a write that never
// happened.
//
// A relay that has moved should say so by being configured with its new URL, which is
// the application's decision to make and not a header's.
//
// WHERE THE PLATFORM ALLOWS IT. On iOS and the native macOS port that inherits its
// networking, NSURLSession follows redirects inside the native stack before the framework
// sees the response -- ConnectionRequest.setFollowRedirects says so in as many words, and
// the port answers "cn1.nativeRedirects" with true. This flag is not a promise there, and
// saying nothing about that would have been a false assurance in the one place it matters
// most. Reported once per process, because an endpoint that redirects has to be fixed at
// the endpoint: nothing in this class can stop it.
warnIfRedirectsCannotBeRefused();
RequestBuilder quiet = b.followRedirects(false).onErrorCodeString(SILENT);
Comment thread
shai-almog marked this conversation as resolved.
String token = getToken();
return token == null || token.length() == 0 ? quiet : quiet.bearer(token);
}

/// Says so, once, when the platform will follow redirects whatever this class asks.
///
/// Not a workaround -- there is none from here. The redirect is taken inside the native
/// networking stack, so this code never sees the response that ordered it and cannot inspect
/// where the request actually went. What it can do is stop the guarantee from being silent,
/// so an endpoint that redirects is a thing somebody knows to fix rather than a token that
/// quietly went somewhere else.
private static void warnIfRedirectsCannotBeRefused() {
if (redirectWarningSaid) {
return;
}
redirectWarningSaid = true;
try {
if (!"true".equals(Display.getInstance().getProperty("cn1.nativeRedirects", "false"))) {
return;
}
} catch (Throwable t) {
Log.e(t);
return;
}
Log.p("Continuity: this platform follows HTTP redirects inside its native networking, so "
+ "the relay cannot refuse them. A redirect from the relay endpoint would carry "
+ "the bearer token to wherever it points. Make sure the endpoint answers "
+ "directly rather than redirecting.");
}

/// So the warning above is said once rather than on every request.
private static boolean redirectWarningSaid;

/// Registered on every request purely to make it silent. See auth().
///
/// A constant rather than an anonymous class per request: it captures nothing, and an inner
/// class would hold its enclosing relay alive for no reason -- which SpotBugs reports as
/// SIC_INNER_SHOULD_BE_STATIC_ANON.
private static final ErrorCodeHandler<String> SILENT = new ErrorCodeHandler<String>() {
@Override
public void onError(Response<String> errorData) {
// Deliberately nothing. The caller reads the response code and decides there.
}
};
}
Loading
Loading