DEV Community

IcyZip
IcyZip

Posted on

Recovering QR-paired browser sessions after Android sleep

A browser-to-browser handoff can look simple in a five-minute test: open one page, scan its QR code on a phone, connect two WebSockets, and move some text. The harder test is leaving both devices alone for a day.

That exposed an uncomfortable failure mode in a QR-paired tool I maintain. Android could remain on Connecting indefinitely, while the desktop still showed old text and neither side made useful progress. Reloading the phone was not a real recovery path either: an expired pair could become Disconnected or Pairing unavailable without producing the fresh QR code the user needed.

The problem was not just a dropped socket. It was a client state model that did not distinguish transport recovery from pairing recovery clearly enough.

Disclosure: this article was prepared with AI editorial assistance from implementation and test notes. The behavior described below was checked against the deployed client plus automated and physical-device evidence.

A WebSocket is not the session

There are at least three different things in this flow:

  1. the current browser transport;
  2. the server-side pairing record;
  3. the browser-side text and encryption state.

A WebSocket can disappear while the pairing remains valid. A pairing can expire while the page still has a stale socket object. The browser can also preserve a useful local draft after both of those are gone.

Treating those conditions as one generic “disconnected” state creates dead ends. A reconnect loop may keep opening transports for an identifier the server no longer knows. Conversely, immediately throwing away the pairing whenever a socket closes destroys a session that could have resumed normally.

The recovery policy became:

explicit user disconnect
    -> clear the old pair and its local draft
    -> leave the disconnected peer terminal
    -> give the initiating primary browser a clean new QR code

transport lost, pairing still valid
    -> replace the stale transport
    -> resume the stored pair
    -> re-establish browser key state

pairing unavailable or expired
    -> stop retrying the old identifier
    -> create a fresh primary pair
    -> show a fresh QR code
    -> preserve the useful local draft
Enter fullscreen mode Exit fullscreen mode

The important distinction is intent. If the user pressed Disconnect, the old pair should stay disconnected rather than silently resume. The primary browser can still offer a clean new pairing. If Android merely slept, changed networks, or restored a tab, recovery of the existing pair should be automatic.

Wake events need to replace stale transports

Mobile browsers do not promise that a backgrounded page will resume with a useful network object. The JavaScript object may still exist even though the underlying connection can no longer make progress.

The client now listens to several lifecycle signals:

  • the document becoming visible again;
  • an online event;
  • a restored page through pageshow;
  • connection states that remain stuck in connecting, opening, error, or peer-offline.

Those signals do not merely call send() again. They request a bounded lifecycle restart, which replaces the old transport and lets the normal pairing handshake run again. A small pending guard prevents several nearly simultaneous wake signals from starting competing reconnect attempts.

This is deliberately different from an unbounded timer that retries forever. The page first asks whether automatic recovery is still allowed. Terminal states such as an explicit disconnect, an expired cryptographic state, or a key mismatch do not silently reopen themselves.

An unavailable pair must lead somewhere useful

Reaching the server successfully does not prove that the old pairing still exists. The server can answer that the requested pair is unavailable.

For the primary browser, that response now triggers a fresh-pair transition:

  • discard the unavailable pairing identifier;
  • clear key material tied to that identifier;
  • remove the stale pair from the URL;
  • ask the server for a new primary pairing;
  • render the new QR code;
  • show an explicit “new pairing created” instruction;
  • keep the local text draft visible.

That last point matters. Pairing state and user-authored text have different lifetimes. The old cryptographic relationship must not leak into the new pairing, but the text the user typed locally is still their work. Preserving it avoids turning network recovery into data loss.

Put the scanner inside the recovery path

Telling a phone user to “scan the QR code again” is incomplete if scanning means leaving the web app, finding the operating system camera, and hoping it opens the correct browser tab.

The recovery UI therefore includes an in-page camera scanner. It uses BarcodeDetector when the browser supports QR detection and requests the rear-facing camera through getUserMedia().

The scanner accepts only the intended pairing URL shape:

  • HTTPS;
  • the current IcyZip origin or another managed icyzip.com origin;
  • the root path;
  • a non-empty pairing identifier;
  • no embedded username, password, or custom port.

Lookalike hostnames, insecure URLs, unrelated paths, and foreign QR codes are rejected. After a successful scan, cancellation, or connection-state change, the camera track is stopped and detached from the video element.

Unsupported detection and denied camera permission are normal product states, not exceptions to hide. In those cases the UI keeps the manual pairing path available and explains why the camera scanner cannot continue.

Capability beats user-agent guessing

The same investigation produced a related Android lesson around clipboard access.

One physical Android Firefox installation exposed navigator.clipboard, which made the feature look available. In practice it rejected the clipboard-read permission name and did not return clipboard text even from a direct user click after another Android application had populated the platform clipboard.

The correct response was not another browser-name exception. The Paste action is now exposed only when the browser can prove the required permission capability as prompt or granted. Missing or rejected permission-query support leaves the action hidden, while the rest of the connected text UI remains usable.

An API existing on window is not the same as an end-to-end capability. Product controls should follow the capability that was actually proven.

Recovery also has a cryptographic boundary

The pairing URL contains a rendezvous identifier, not the text or file encryption key. Current browser clients create P-256 ECDH key pairs and derive separate text and file keys with HKDF-SHA-256 for AES-GCM.

That means reconnect logic must preserve the right browser state for a valid pair, but must not carry derived trust across an unavailable pairing into a newly created one. A fresh QR code represents a fresh pairing and a fresh browser key exchange.

Payload encryption also does not erase metadata. The relay still handles routing and can observe operational information such as timing, connection details, message sizes, and file-offer metadata. Recovery copy and privacy copy have to describe the same system.

Test the state transitions, not just the labels

It would be easy to make the bug appear fixed by hiding Connecting and showing a QR code unconditionally. That would be a false green if valid sessions stopped resuming or text stopped moving afterward.

The recovery tests therefore assert forward progress as well as UI state. They cover:

  • replacement of a stale socket after a simulated Android wake;
  • automatic resume when the pairing remains valid;
  • creation of a different pairing identifier when the old one is unavailable;
  • removal of the stale identifier from the address bar;
  • preservation of the local text draft;
  • a visible fresh QR code and rescan instruction;
  • accepted and rejected QR-origin cases;
  • unsupported and camera-denied scanner fallbacks;
  • camera-track shutdown after a successful scan;
  • bidirectional text exchange after recovery;
  • explicit disconnect invalidating the old pair while the primary browser offers a fresh QR code.

The browser-emulated cases are supplemented by a real older Android phone because mobile lifecycle and clipboard behavior are exactly where desktop-only tests become overconfident.

The broader lesson

Connection recovery is not one retry function. It is a product state machine spanning transport state, server pairing state, browser-local work, cryptographic identity, and explicit user intent.

Once those are modeled separately, the UI decisions become clearer:

  • retry a transport when the session is still valid;
  • create and explain a fresh pairing when it is not;
  • preserve user-authored work without preserving stale trust;
  • offer an in-context scanner, but keep a non-camera fallback;
  • prove that recovered sessions can still move data.

The current behavior and its security/metadata boundaries are live in IcyZip's implementation notes.

Top comments (0)