MobiWeb Help

Offline Support: Technical Design

Can MobiWeb make a remote SPA / PWA work offline without modifying the web app? This page records the investigation, the verdict per platform, and the Android implementation (gated by OFFLINE_ENABLED, default false). It is written for MobiWeb maintainers. For setup, start with Offline Support; web-app developers should read Offline Support and Write Queue (Android).

Verdict

Capability

Android

iOS

Offline launch + last-seen pages / GET data

Yes, no web changes (Layer A)

No parity (see iOS)

Offline fallback when the web app has its own SW

Partial, via HTTP cache (Layer C)

No (SWs disabled in this WKWebView)

Offline UX (auto-retry on reconnect)

Yes

Not implemented (no didFail* handling)

Offline writes

Opt-in AndroidOffline queue

Not implemented

Reads can be transparent; writes cannot. MobiWeb can't know what a failed POST means (idempotency, conflicts, what the UI should show), so replaying writes needs the web app's cooperation. The write queue is therefore an opt-in bridge: the web app has to call it.

Preconditions on the web app and backend

"No web-app changes" holds for the code, but the result depends on how the app is deployed. Check these before expecting data offline:

Precondition

If it doesn't hold

The API is same-origin with the page (app.example.com serves both).

Cross-origin API responses are never cached (only cross-origin script/style/font/image are), and the write queue rejects cross-origin URLs. Only the shell works offline.

Reads are GETs and the backend does not send Cache-Control: no-store on them.

Frameworks such as Spring Security emit no-cache, no-store on every authenticated response by default. MobiWeb honors it, so such APIs are never available offline. Inspect the response headers.

The web app has no service worker of its own (any scope, incl. a push SW).

Layer A steps aside; only Layer C (HTTP cache) remains.

INITIAL_URL is the final URL: scheme https, final host and path.

A host redirect (e.g. to www.) leaves the SW unregistered on the real host. A path redirect is covered by the last-navigation fallback, but set the final path anyway. http origins get no SW at all.

Logout goes through AndroidCookie.clearCookies(...) or the REDIRECT_URL_PREFIX path.

An SPA that logs out with only POST /logout + localStorage.clear() leaves the previous user's cached API JSON servable offline to the next user of a shared device. Hard requirement.

Route-level code splitting is tolerated.

Lazy chunks of never-visited routes are not cached: offline, such a route boots from the shell and then fails with the SPA's own chunk-load error, not a graceful fallback.

Session auth is cookie based, or the token flow tolerates failures.

Bearer-token SPAs keep the token in local storage, but one that validates/refreshes it on boot via POST or a no-store GET will fail offline and may log itself out.

The page CSP allows worker-src 'self' (or script-src 'self').

register() is rejected; only a console.warn shows it. Layer C only.

Also note: navigator.onLine reflects system connectivity, so an SPA that gates on it shows its own offline UI (fine). A boot "health" GET is served stale from the cache, so the SPA may believe it is online.

Alternatives considered

  • Native tee-cache in shouldInterceptRequest (re-issue every GET natively, cache to disk). Rejected: WebResourceRequest exposes no request body or response cookies, so native would have to re-implement cookies (second cookie jar + Set-Cookie write-back), CORS/preflight, redirects, Range /206 and streaming, lose HTTP/2 connection reuse, and block an IO thread per request. Its only advantages (works without SW support, next to a foreign SW, and could cache cross-origin API responses) don't justify that.

  • Cache-mode toggle only (Layer C alone). Under-credited but insufficient: LOAD_CACHE_ELSE_NETWORK does apply to XHR/fetch and ignores freshness, but it fails on no-store, on never-cached routes, on Chromium's own eviction and on Vary. Layer A adds deterministic storage of 200s regardless of HTTP cache headers, the SPA route fallback, the lie-fi guard and an explicit wipe. On no-store APIs the two layers converge: neither helps.

  • Local HTTP proxy via ProxyController. Rejected: HTTPS is CONNECT-tunnelled, so the proxy sees ciphertext unless the app MITMs itself with an installed CA. Unacceptable for medical data.

  • Bundling the SPA into www/. Not pursued: it would solve shell offline on both platforms (and is the only route to iOS parity without App-Bound Domains), but it changes the product model (remote SPA, build pipeline, app-store releases per SPA change) and makes the API cross-site from capacitor://localhost (SameSite cookies, CORS). Listed as the "relax the constraint" option.

  • WebViewAssetLoader (bundled assets only), Chromium flags (not settable in production), Capacitor server.url (changes Capacitor's notion of the app URL, adds no caching) and WebView HTTP-cache tuning (no public API): not applicable.

Why the old service worker never worked

www/service-worker.js (now removed) lived on the local shell origin (https://localhost/capacitor://localhost). It was never registered, and even if it had been, a service worker only controls its own origin. The real app loads from the remote origin (INITIAL_URL/RemoteContentURL), so the bundled SW could never see its requests. It also precached paths that don't exist locally, so install would have failed.

Android design

Layer A: injected service worker

The remote site never hosts a SW, so MobiWeb serves one for it from the same origin:

  1. OfflineManager.interceptRequest answers https://<app-host>/__mobiweb_offline_sw.js with the bundled android/app/src/main/assets/mobiweb/offline-sw.js. It does this through both WebViewClient.shouldInterceptRequest and ServiceWorkerController's client, because SW script and update fetches can bypass the WebViewClient. Every other request passes through untouched.

  2. After each app-origin page load, an injected shim (assets/mobiweb/register-sw.js) registers it with scope: '/'.

  3. The browser itself runs the SW. Cookies, redirects, CORS and fetch() therefore behave exactly as in a browser, and the origin never changes.

Warm-up. The SW registers only after the first page has loaded, so it never saw that page's document or bundles. To make the first online session enough for an offline cold start:

  • activate caches the start URL.

  • The shim then posts the current page plus its same-origin page assets (from performance.getEntriesByType('resource'), script/link/css/img/font initiators only) to the SW as mobiweb:warm. Every warm list also retries the start URL.

Cross-origin CDN assets are not warmed; they are cached the next time the page requests them.

If the start URL redirects for a logged-out user (e.g. to /login), the redirected response is never stored, so nothing is cached until the first logged-in full page load of the app. A login that only changes the SPA route (no reload) does not count; the next warm list, posted on the next page load, picks the start URL up once it answers 200.

SW caching rules (cache mobiweb-offline-v1):

  • GET only, network-first, falling back to the cache when the network fails. A navigation waits at most 8 s for the network before it serves a cached copy (lie-fi guard). This is a behaviour change while online too: on a very slow network the user runs the cached document (and the bundle set it references) for that session; the late network response is still stored for the next load.

  • Cached: same-origin navigations and any same-origin GET (HTML, JS, CSS, images, API JSON). For cross-origin requests, only the static destinations script/style/font/image (CDN shell assets), including opaque responses.

  • Never cached:

    • Cache-Control: no-store, whether on the response, the request header, or fetch(…, {cache:'no-store'}). One exception, the app shell: the start page or /, when the response is HTML, is stored even if marked no-store. Many SPAs send no-store on index.html only to force fresh deploys, and without a stored shell nothing can launch offline (found testing demo.realworld.show). It is scoped to those two URLs because other HTML pages may be server-rendered with user data, and like everything else it is served only when the network fails.

    • non-200 responses, and redirected responses (Chromium rejects a redirected response served to a navigation)

    • Range requests, audio/video, event streams (Accept or Content-Type: text/event-stream), Content-Disposition: attachment, and bodies over 10 MB (by Content-Length)

    • /__mobiweb* and /_oauth_callback

  • Offline navigation fallback: exact URL → cached start URL → last successfully loaded document → cached origin root / (lets SPA client routing boot on any route) → network error, at which point the native error overlay takes over. Fallbacks ignore Vary, so a server sending Vary: Accept still matches. The last-loaded document is kept under the synthetic key /__mobiweb/last-navigation and covers an INITIAL_URL whose path redirects (/ → /doctor): that navigation yields an opaqueredirect that can never be stored.

  • Eviction, oldest first: at most 500 entries, at most ~100 MB by Content-Length (unknown sizes count as 0), and at most 50 opaque (cross-origin, uninspectable) entries, which Chromium pads heavily in its quota accounting. Trimming runs every 25 stores and on every SW activation. Cache Storage shares the origin's quota with the SPA's own IndexedDB / localStorage.

  • The store work is registered with waitUntil synchronously in the fetch handler. A slow network response that arrives after the lie-fi timeout has already served the cached copy is therefore still cached.

Coexistence:

  • If any registration has a worker that isn't ours, whether active, waiting or installing, the shim does not register ours (theirs wins), and it reports the conflict through AndroidOffline._swConflict.

  • The waiting/installing check matters: re-registering ours over a web-app worker waiting behind it would replace that worker every time, so it could never activate.

  • If the web app registers a SW at / later, the browser replaces ours, and the shim stays out of the way from then on.

  • On a conflict, ours steps aside completely:

    • Registrations made up only of our worker are unregistered. For example, ours at / registered before the web app's at a narrower scope would otherwise keep controlling the rest of the origin.

    • mobiweb-offline-v1 is deleted.

    • A registration that also holds a web-app worker is left alone, because unregistering it would remove theirs too.

    • The conflict is persisted (sw_conflict). While it is set, native leaves ServiceWorkerWebSettings on LOAD_DEFAULT, because that setting is process-wide and would change the web app's own worker's fetch() behaviour while offline. Only the WebView-level fallback (Layer C) applies.

  • Remaining window: if the web app registers its SW only after our shim ran on the very first load, ours controls that first session; the browser then replaces ours (same scope) or the next load's shim removes it (other scope).

Kill switch: when OFFLINE_ENABLED is off, app-origin page loads inject assets/mobiweb/unregister-sw.js. It unregisters a MobiWeb SW left behind by an earlier build that had the flag on, and deletes mobiweb-offline-v1. The web app's own SWs are untouched.

Layer C: native baseline

ConnectivityMonitor (registerDefaultNetworkCallback, needs ACCESS_NETWORK_STATE):

  • Offline → setCacheMode(LOAD_CACHE_ELSE_NETWORK), so the Chromium HTTP cache serves whatever it still holds. Online → LOAD_DEFAULT.

    • It is always set on ServiceWorkerController.getServiceWorkerWebSettings(), because requests issued by our SW follow the SW settings, not the WebView's. That way the HTTP cache backs up SW cache misses.

    • It is set on the WebView only while our SW is not in control (web app owns a SW, or ours isn't installed yet). With our SW active, the WebView stays on LOAD_DEFAULT so every page request goes through the SW unaltered. (This was first added for a device symptom, JS/CSS failing offline while the document loaded, that turned out to be the startup race below; it is kept as a safeguard.) The registration shim reports "our SW is active" through AndroidOffline._swActive(). Native persists it, so an offline cold start knows it before any page loads. A detected conflict clears it.

  • Online state comes from the NetworkCallback arguments, not from re-querying the active network inside the callback (which is racy). A stale onLost for a network the device has already switched away from is ignored.

  • False error overlay (found on device). With our SW in control, its network-first attempt for the navigation fails offline and Android reports that to onReceivedError as a main-frame error (ERR_INTERNET_DISCONNECTED), even though the SW then serves the cached page. So while our SW is active, a main-frame network error doesn't show the overlay immediately: after ~1 s the app checks location.protocol and shows it only if the WebView really ended on Chromium's error page.

  • Startup race (found on device, pre-existing code). At launch the start URL is loaded twice (from onCreate, and by the "redirect away from Capacitor's localhost " path). The redirect used to call stopLoading() before checking whether the WebView was already at the start URL. Online the network is slow enough that this never mattered; offline the SW commits the cached page so fast that stopLoading() landed after it, cancelling all its JS/CSS (blank page, or HTML without styles). The check now runs before stopLoading().

  • On reconnect, if the native error overlay is showing it retries automatically, and the write queue is flushed.

  • Every transition dispatches mobiweb:connectivity { online }.

Layer C works even when Layer A is skipped, but it depends on HTTP cache headers and Chromium eviction.

Write queue: AndroidOffline (opt-in)

Method

Returns

Notes

enqueue(json)

{"ok":true,"id"}/{"ok":false,"error"}

json = {url, method?="POST", headers?, body? (string), maxAttempts?=10, meta?}; meta is opaque JSON (≤ 4 KB) echoed in the item's events

list()

JSON array of queued items

Includes attempts, createdAt

remove(id)

boolean

Aborts the item's request if it is in flight

clear()

—

Empties the queue

flush()

—

Replay now, cutting any backoff short (also automatic on reconnect, and on the first app page load after start)

clearCache()

—

Deletes the SW cache (deferred to the next app-origin load if needed)

isOnline()

boolean

getStatus()

{"online","serviceWorkerConflict","queueLength"}

Rules:

  • method must be one of POST/PUT/PATCH/DELETE. url is resolved against INITIAL_URL and must be on the app origin: same scheme, host and port.

  • enqueue is only accepted while the WebView's main frame shows an app-origin page. A foreign page (e.g. a provider consent page) gets {"ok":false}.

  • Header names or values containing CR/LF are rejected at enqueue.

  • Cookie, Host and Content-Length headers are managed natively; Origin/Referer default to the app origin and X-Requested-With to the package name (as the WebView sends it).

  • Limits: body ≤ 1 MB, meta ≤ 4 KB, at most 500 queued items ({"ok":false,"error":"Queue full…"}). Every store operation re-encrypts the whole file, which is why the queue is bounded.

  • Replay sends the WebView's cookies and stores Set-Cookie. Capacitor's CapacitorCookies installs a process-wide CookieHandler backed by the WebView CookieManager, and HttpURLConnection uses it automatically. The replayer only handles cookies by hand when no such handler exists, so there is never a duplicate Cookie header.

  • Items are stored in an app-private file, Tink AES-256-GCM encrypted with the same Keystore-backed keyset as CookieSecureStore. They survive app restarts.

  • Only genuinely corrupt content (bad ciphertext / JSON) resets the file. A transient failure (Keystore or I/O) leaves it in place, and replay retries later.

  • Replay is strict FIFO:

    • 2xx → mobiweb:offlineQueueItemSent.

    • 3xx, 4xx, or maxAttempts reached → removed + mobiweb:offlineQueueItemFailed. Redirects are not followed: the common 3xx is a 302 to the login page on an expired session, i.e. the write was not applied.

    • 5xx → kept, counts as an attempt, replay stops, and it retries with exponential backoff (5 s doubling to a 5 min cap).

    • Network error (no response) → kept, does not count as an attempt (the server never processed it), replay stops, same backoff.

    • A new enqueue does not cut a pending backoff short, so a failing head item doesn't burn its attempts. Reconnect and flush() do retry immediately.

    • If the head item was sent (or failed terminally) but can't be removed from the store (e.g. disk full), its event is still dispatched and its id is remembered in memory: this process never sends it again, and replay pauses until the removal succeeds. A process restart loses that memory and may re-send it.

    • If the queue is wiped or the item is removed while its request is in flight, the request is aborted and its response discarded: no cookie write-back and no item event. The abort is best-effort: a response that has already been read by the time the wipe runs is still processed by the cookie handler, and the logout's own removeAllCookies then clears it.

    • An unsupported HTTP method is dropped as failed; any other ProtocolException (Android's HttpURLConnection raises one for a mid-response connection drop) is treated as a network error and the item is kept.

    • Each flush ends with mobiweb:offlineQueueFlushed { remaining }.

  • Event detail: { id, status, attempts, body?, error?, meta? } (response body truncated to 64 KB).

  • Queue events are delivered to the current page. While no app-origin page is loaded (Capacitor's local shell at startup, the native error overlay), they are held (last 200) and delivered on the next app-origin page load. The startup replay itself waits for that first app page. mobiweb:connectivity is never held.

async function saveNote(note) { try { return await fetch('/api/notes', {method: 'POST', body: JSON.stringify(note), headers: {'Content-Type': 'application/json'}}); } catch (e) { if (!window.AndroidOffline) throw e; const res = JSON.parse(AndroidOffline.enqueue(JSON.stringify({ url: '/api/notes', method: 'POST', body: JSON.stringify(note), headers: {'Content-Type': 'application/json'}, }))); if (!res.ok) throw new Error(res.error); return {queued: res.id}; } } window.addEventListener('mobiweb:offlineQueueItemFailed', (e) => { console.warn('Queued write rejected', e.detail.id, e.detail.status, e.detail.body); });

Wipe

Both logout paths wipe the SW cache and the write queue, aborting any in-flight replay:

  • the JS AndroidCookie.clearCookies(...)

  • the native REDIRECT_URL_PREFIX logout (clearCookiesAndRedirect)

Otherwise a previous user's cached data or queued writes could reach the next session. AndroidOffline.clear()/clearCache() wipe each one separately. A cache wipe requested while the page isn't on the app origin is deferred to the next app-origin page load.

Security note: the bridge is reachable from every frame

addJavascriptInterface objects are injected into every frame of the WebView, including cross-origin iframes embedded in the app page. AndroidOffline.enqueue validates the target URL and the main-frame page, not the calling frame, and replays carry the user's cookies with Origin/Referer set to the app origin. A malicious cross-origin iframe inside the app could therefore queue an authenticated write that passes an Origin-only CSRF check. This is the same trust model as every other MobiWeb bridge, but this is the first bridge that writes with the user's credentials. Mitigations if the app embeds untrusted frames: keep server-side CSRF tokens in the request (headers/body), or move the bridge to WebViewCompat.addWebMessageListener with an allowed-origin list (origin-scoped and frame-aware).

Limitations

  • Only GETs are cached. A POST-based read API won't be available offline.

  • no-store responses are deliberately never cached (except the app shell, see the SW rules), so endpoints that send it are not available offline.

  • Cached responses are protected by the Android app sandbox only; they are not encrypted at rest. The write queue is encrypted.

  • Write-queue bodies are strings only (JSON / form text), not binary or multipart.

  • The cache only contains what the user has visited, plus the start URL and the first page's same-origin assets (warm-up). Nothing else is precached.

  • A web app with its own SW gets only Layer C. A page CSP with a restrictive worker-src also blocks register(); the shim only logs a console.warn.

  • The kill switch unregisters the whole registration at scope /. If a web-app worker was waiting behind ours, it goes too, and the web app re-registers it on its next load.

  • OfflineManager.start() replaces Capacitor's own ServiceWorkerClient (used only for its local https://localhost assets, where no SW exists) and stop() resets it to null.

  • The app host is the INITIAL_URL host. If the site redirects to another host (e.g. www.), the SW is never registered there and wipes don't reach it. Set INITIAL_URL to the final host.

  • ACCESS_NETWORK_STATE is declared in the manifest regardless of the flag. It is a normal permission with no runtime prompt. With the flag off, startup also deletes a queue file left by a build that had it on.

  • "Online" means the default network has NET_CAPABILITY_INTERNET, not VALIDATED: a captive portal counts as online. The replayer copes (no response = not an attempt, backoff), but the cache-mode flip and mobiweb:connectivity don't fire there. VALIDATED was not used because some enterprise networks never validate, which would pin the app in offline mode while it is online.

  • Backup exposure (pre-existing, now wider). The manifest has android:allowBackup="true" with no fullBackupContent/dataExtractionRules, so Auto Backup and device-to-device transfer include app_webview/, i.e. the WebView's localStorage / IndexedDB and now the SW cache with full API response bodies. The encrypted queue is Keystore-bound and useless off-device. Decide whether to exclude app_webview from backups or disable backup for this app.

  • Still to verify on a device: whether Chromium sends the SW main-script fetch through ServiceWorkerClient and/or WebViewClient (both are hooked), and PATCH replay (the desktop JDK used by unit tests rejects PATCH; Android's HttpURLConnection accepts it).

iOS

Not implemented in this round. Findings:

  • WKURLSchemeHandler cannot register http/https, so there is no public API to intercept the remote origin's requests. That rules out the Android Layer A trick of serving a SW script.

  • Service workers in WKWebView require WKAppBoundDomains (Info.plist) plus limitsNavigationsToAppBoundDomains. Even then, the SW script has to come from the network, which means the web app would have to host it.

  • Options considered:

    • Custom-scheme proxy: load the app under e.g. mobiweb://example.com and proxy it through URLSession with a cache. This changes the origin, which breaks HttpOnly session cookies, localStorage/ IndexedDB continuity, CORS (the Origin header becomes the custom scheme), OAuth redirects and third-party iframes. Rejected for now.

    • loadSimulatedRequest (iOS 15+) / loadHTMLString(baseURL:) with a cached HTML snapshot keeps the origin, but covers the document only: subresources still hit the network and fail.

    • fetch/XHR JS shim (WKUserScript) that falls back to a native cache through a message handler: covers API GETs, but not the initial document, scripts or styles.

  • NSURLCache/URLCache.shared does not apply: WKWebView's network process ignores the app's URL cache. WKWebsiteDataStore has no offline-mode toggle. WKContentRuleList only blocks.

Two shippable iOS options, in order of value:

  1. Parity with a one-file hosting change (no SPA code). Add WKAppBoundDomains = [app host] and set limitsNavigationsToAppBoundDomains, publish the same offline-sw.js as a static file on the web host (or an edge/CDN rewrite rule), and inject the same register-sw.js through a WKUserScript. This reuses the SW, the shim and their node tests unchanged. To verify on a device: App-Bound Domains limits top-level navigations to the listed domains (≤ 10); native OAuth runs in ASWebAuthenticationSession and is unaffected, and the WebRTC call iframe is a subframe, but test both. Open question: does a static file on the web host count as "modifying the PWA"?

  2. Native baseline now (Layer C equivalent). NWPathMonitor + didFailProvisionalNavigation/didFail → offline screen with auto-retry, and while offline load the main document with URLRequest(cachePolicy: .returnCacheDataElseLoad). WebKit propagates the main-resource cache policy to subresource loads, so the shell and static assets still in WebKit's disk cache come back; API JSON depends on its cache headers. Plus an AppleOffline queue bridge mirroring AndroidOffline.

Testing (Android)

Automated:

  • yarn test:offline-sw runs the node:test suite (android/app/src/test/js/offline-sw.test.mjs). It executes the real offline-sw.js, register-sw.js and unregister-sw.js against an in-memory Cache Storage (Vary-aware, with spec-strict waitUntil) and a mock navigator.serviceWorker. It covers:

    • SW: interception rules; network-first; no-store/ redirected / non-200 / opaque / streaming handling; offline, SPA-shell and Vary fallbacks; the lie-fi timeout and late caching; warm-up; eviction; wipe; activation

    • shims: registration, conflict detection (active / waiting / installing) and the kill switch

  • cd android && ./gradlew :app:testDebugUnitTest runs the Robolectric + MockWebServer tests (app/src/test/java/mobiweb/offline/). They cover:

    • SW-script serving and the shim assets

    • enqueue validation: origin incl. port, method, header stripping, CR/LF

    • the encrypted queue store: corrupt vs transient failures

    • replay: FIFO, 2xx / 4xx / 5xx / max attempts, network errors (not counted), offline, cookies incl. the default CookieHandler path, redirects, wipe and remove(id) during an in-flight request, remove failure (no duplicate send), backoff vs non-forced flush

    • manager: main-frame origin gate, held events, startup replay deferred to the first app page, body / meta / queue-length caps

    • ConnectivityMonitor: callback-driven state, stale onLost, dedupe, start/stop idempotence

    • SW additions: last-navigation fallback, byte budget and opaque cap

    • Not covered: start()/ServiceWorkerController wiring and the onPageFinished injection need a device.

Manual, on a device:

  1. Set OFFLINE_ENABLED=true (for example in the debug buildType) and run the app.

  2. In chrome://inspect → Application, check that /__mobiweb_offline_sw.js is active and that the mobiweb-offline-v1 cache fills while you browse.

  3. After one online launch, turn on airplane mode, cold-start the app, and check that the start page (warm-up) and previously visited pages and GET data render.

  4. On a route you never visited, check that the start-URL fallback (or the error overlay) appears, then turn airplane mode off and check that it auto-retries.

  5. Offline, run AndroidOffline.enqueue(...), relaunch online, and watch the mobiweb:offlineQueue* events.

  6. Run AndroidCookie.clearCookies(false), or hit the REDIRECT_URL_PREFIX logout, then check that the cache and AndroidOffline.list() are empty.

  7. Build with the flag off over a build that had it on, then check that the SW is unregistered, mobiweb-offline-v1 is gone and files/mobiweb_offline_queue.bin was deleted.

  8. Set INITIAL_URL to a path the server redirects (e.g. / → /doctor), go through one online launch, then an offline cold start: the last-navigation fallback must render the app.

  9. Expired session: queue a write offline, invalidate the session server-side, reconnect; expect offlineQueueItemFailed with status 302/401, never Sent.

  10. Release (R8) build: AndroidOffline.* still callable from the console.

Minimum device matrix (minSdk 24): API 24/25 with the last WebView that supports them (119), API 28 (WebView ~138), API 30–33 current WebView, API 35/36 current WebView; plus one low-free-space device and one R8 release build.

18 October 2026