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