Files
omnigent-ai--omnigent/tests/e2e_ui/mobile/test_android_shell.py
T
Bryan Li a4ef23f71e feat(android): native Android WebView shell (#1604) (#1704)
* feat(android): native Android WebView shell (#1604)

Add a thin native Android shell that loads the server-served web UI, the
third native runtime of the same bundle alongside the iOS WKWebView shell
(web/ios) and the Electron desktop shell. Mirrors the iOS shell's
native<->web contract so the SPA needs no per-feature branching.

Web side (one bundle, multiple runtimes):
- nativeBridge.ts: add "android" to the shell `kind` union AND the
  nativeApi() runtime guard (the guard, not just the type, is what makes
  the bridge live), plus an isAndroidShell() sibling to isIOSShell().
- index.css: fold Android-measured insets into --omnigent-safe-* via
  max(env(...), var(--omnigent-android-safe-area-*, 0px)), universally —
  no isAndroidShell() branching; zero effect off the Android shell.

Android module (web/android, Kotlin):
- Web->native bridge via WebViewCompat.addWebMessageListener,
  origin-allowlisted to the pinned server + main-frame gated — the
  structural equivalent of the iOS isMainFrame/frame-origin check, so a
  sandboxed agent-HTML iframe can't reach the native surface.
- OS notifications with tap routing (cold + warm start, consume-once
  replay cache), best-effort badge, POST_NOTIFICATIONS runtime request.
- Edge-to-edge insets measured natively and pushed to CSS (Android
  WebView can't rely on env(safe-area-inset-*) alone).
- File upload (WebChromeClient.onShowFileChooser) and microphone
  (onPermissionRequest, granted to the pinned origin only + RECORD_AUDIO).
- Downloads incl. blob:/data: exports via a fetch->base64->MediaStore
  bridge, which closes #969 (the iOS shell drops these).
- Native connect / recent-servers screen; system-back + predictive-back.

Builds clean: gradlew :app:assembleDebug :app:lintDebug = BUILD
SUCCESSFUL, 0 lint errors (JDK 17, Gradle 8.9, compileSdk 35, minSdk 28).
Not yet exercised on a device. Sidebar edge-swipe and the native floating
bars are deliberately deferred to the web in-page fallbacks (see README).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): keep the OIDC redirect chain in the WebView (#1708)

The shell handed any off-origin top-level navigation to the external
browser (a fail-closed choice from the bridge-hardening work). That
kicked the OIDC login redirect (the server bouncing the main frame to
the IdP) out to Chrome, where auth completed and the session cookie
landed — so the in-app WebView never received the session and login
silently failed.

shouldOverrideUrlLoading now lets all http/https navigation, including
the off-origin OIDC redirect chain, load in the WebView — mirroring the
iOS shell. Only top-level non-http(s) schemes (mailto/tel/intent/custom)
are still handed to the system. This is safe because the native bridge
is origin-allowlisted (addWebMessageListener) and the window.omnigentNative
facade is injected only on the pinned origin, so a foreign auth page
loaded top-level can't reach native.

Verified on a Pixel-6 emulator (API 34) against a live OIDC deployment:
before, logcat showed an ACTION_VIEW handoff of auth.joyful.house to
com.android.chrome and Chrome took the foreground; after, the IdP
(Authentik) login page renders inside the app and login completes the
round-trip in the WebView.

Does NOT cover an IdP that federates to Google social login — Google
blocks embedded WebViews (disallowed_useragent), which needs a Custom
Tabs hand-off with a session hand-back. Tracked in #1708.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(android): brand the app icon + Connect screen to match iOS

The app icon was a generic placeholder and the Connect screen was bare
Material chrome — neither matched the iOS shell or the Omnigent brand.

- App icon: replace the placeholder with the Omnigent starfish (converted
  from the shared platform-assets brand source — the same favicon/iOS
  AppIcon mark) as the adaptive foreground, a starfish-silhouette
  monochrome layer for themed icons, on the brand dark-navy background.
- Connect screen: mirror the iOS ConnectView — the omnigents wordmark
  (which embeds the starfish) on top, a muted subtitle, a "Server URL"
  label, a bordered field, a filled dark primary button, an inline error
  line, and bordered recent-server rows.
- Brand colors: port the iOS DesignTokens palette (foreground #11171C,
  border #E8ECF0, primary #11171C, muted, error) into colors.xml plus a
  values-night/ dark variant. Type uses the system font (Roboto) — the
  same native-font choice the web UI and iOS make (--font-sans is a
  system stack), so the setup screen reads consistently across platforms.

Built + screenshot-verified on a Pixel-6 emulator: the wordmark, colors,
field, and button render at parity with the iOS setup screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(android): authenticate via Chrome Custom Tabs (fixes Google + passkey) (#1708)

Per RFC 8252, native apps must not run OAuth in an embedded WebView — Google
blocks it (disallowed_useragent) and passkeys/WebAuthn don't work there. The
Layer-1 stopgap (load the IdP in the WebView) only worked for IdP-native
username/password. This does it correctly: authenticate in a Chrome Custom Tab.

Flow (reuses the server's existing browser-login endpoints — the same ones the
`omnigent login` CLI uses, no server change):
- OmnigentWebViewClient intercepts the off-origin OIDC redirect (a server
  redirect — no user gesture — to the IdP) and triggers native login instead of
  ever loading the IdP in the WebView. A gesture'd off-origin nav is treated as
  an external link and handed to the system browser.
- OidcLoginManager: POST /auth/cli-login -> {ticket, login_url}; open login_url
  in a Custom Tab (Google/passkey/any IdP all work in a real browser); poll
  GET /auth/cli-poll?ticket until it returns the session JWT.
- The Custom Tab and the WebView have isolated cookie stores, so the session is
  bridged explicitly: the polled JWT is exactly the session-cookie value (the
  server validates the same HS256 JWT as cookie or Bearer), so MainActivity
  injects it as the __Host-ap_session cookie via CookieManager and reloads
  authenticated, then brings itself back over the Custom Tab.

Verified against the live OIDC server on an emulator: connect -> the shell
intercepts the redirect, POSTs cli-login, opens the Custom Tab to the login URL,
and polls cli-poll (202 pending) — the IdP never loads in the WebView. The login
round-trip (token -> cookie -> authenticated reload) needs a real device with a
set-up browser to complete; pending on-device confirmation.

Adds androidx.browser (Custom Tabs). Follow-up #1708. The `cli-` endpoint naming
is now a misnomer for shared CLI+mobile use — proposed to maintainers to alias,
deferred for blast radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): use the system browser for login + return-to-app bridge (#1708)

Verified on-device: the in-app Custom Tab rendered the IdP (Authentik) flow
page blank, while the full system browser works. Switch the login hand-off from
a Custom Tab to a plain ACTION_VIEW browser intent — still RFC 8252 compliant
(the system browser is the canonical external user-agent; Google, passkeys, and
password managers all work). Drops the androidx.browser dependency.

Return-to-app: the poll completes while the browser is foreground, and Android's
background-activity-launch rules block us from foregrounding ourselves, so we
both attempt a reorder-to-front (works within the grace period) and post a
"Signed in — tap to return" notification as the reliable path back.

End-to-end verified against the live OIDC server: login -> session JWT polled ->
injected as __Host-ap_session -> WebView reload is authenticated (server: GET /
304, WebSocket /v1/sessions/updates accepted, /v1/sessions 200), and the app
returns to the foreground. Fully seamless auto-return (browser auto-closing on a
custom-scheme redirect) needs a small server change — tracked in #1708.

Auth-flow logging redacts URLs (OAuth state/PKCE/ticket) — logs origins only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): apply the safe-area insets so mobile chrome isn't under the status bar

The header's top-left sidebar toggle (and the sidebar/panels) were untappable on
Android: the WebView is edge-to-edge and the OS status bar (128px on the test
device) overlaps `.chat-header` (which is `absolute top-0`), so the system
swallows the tap. Root cause: every safe-area rule in index.css was gated on
`[data-ios-native]`, and several used raw `env(safe-area-inset-top)` — which is 0
in Android WebView. The native side already injects the real inset via
`--omnigent-android-safe-area-*`; the web side just never consumed it on Android.

- AppShell sets `data-android-native` for the Android shell (alongside the
  existing iOS/Electron markers).
- index.css extends the safe-area rules to `[data-android-native]` — the header
  offset, conversation/terminal top padding, sidebar + panel padding, composer
  bottom padding, and the drawer slide — and sources them from `--omnigent-safe-*`
  (which folds env() on iOS and the injected var on Android) instead of raw env().
  The iOS-only floating Liquid-Glass bar rules stay `[data-ios-native]`.

Verified on the emulator: the header drops below the status bar, the toggle is
tappable, the sidebar opens with its header/footer clearing the system bars.

Android: gate WebView remote debugging behind BuildConfig.DEBUG (enable
buildConfig); drop the inset diagnostic logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): themed (monochrome) icon shows the starfish eyes, not a blob

The monochrome layer was just the solid body path, so the Android 13+ themed
icon rendered as an eyeless silhouette. A monochrome icon is single-tint, so the
eyes have to be transparent holes: build it from the body + baby starfish with
the eye circles and smile punched out via fillType="evenOdd" (filled body, holes
where the eyes/mouth are). Scaled to match the full-color foreground.

(Validated by build/aapt; the themed-icon appearance needs a launcher with
themed icons enabled — the test emulator's launcher doesn't apply them.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): harden the OIDC login flow (review round 1)

Adversarial review (Codex + Opus) of the browser-login flow:

- Use-after-destroy (HIGH): the poll runs up to 5 min on a background thread, so
  it can complete after onDestroy and post onSessionToken into a destroyed
  WebView (webView.loadUrl after webView.destroy()). Guard onSessionToken (and
  the async setCookie callback) on isDestroyed/isFinishing/::webView.isInitialized,
  and hold the session callback in a field that shutdown() nulls.
- Activity leak (MED): the in-flight poll pinned the Activity (via the bound
  callback) for up to 5 min. shutdown() now uses shutdownNow() to interrupt the
  poll's sleep so the task exits promptly and releases the host.
- Login-loop guard (MED): cap browser-login relaunches at MAX_LOGIN_ATTEMPTS so a
  rejected cookie / expired token can't loop the browser forever; the counter
  resets in onPageReady once a pinned-origin page actually loads.
- POST /auth/cli-login (LOW): set Content-Length: 0 on the bodyless POST (strict
  servers/WAFs can 411 otherwise).
- Logging (LOW): route the auth-flow traces through authLog() (Logging.kt), which
  only emits in debug builds — no auth event traces in release logcat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): guard login routing on scheme + validate token shape (review round 2)

Two robustness fixes surfaced by the Gemini adversarial pass (the must-fixes
all three models converged on landed in the prior commit):

- OmnigentWebViewClient.onPageStarted: only treat a real http(s) off-origin
  landing as an OIDC bounce. A null / about:blank / chrome-error:// URL is a
  failed or transitional load of the pinned server (e.g. it's offline), not an
  IdP redirect — the old check popped the system browser for it. Mirrors the
  http(s) gate shouldOverrideUrlLoading already had. Facade injection is now
  explicitly gated on the pinned origin (a non-http off-origin URL falls
  through the first gate instead of returning).

- MainActivity.onSessionToken: reject a token that isn't JWT-shaped before
  building the cookie string. Defense-in-depth — the token is interpolated into
  the cookie value, so a ';'/whitespace-bearing value could smuggle attributes
  (e.g. Domain=, defeating __Host-). A real HS256 JWT always passes.

Also folds in a behavior-preserving simplifier pass: name the repeated 10s HTTP
timeout (HTTP_TIMEOUT_MS), hoist duplicated originOf() lookups into locals, and
correct stale "Custom Tab" comments to "system browser".

Build + lint green (0 errors); 32/32 web bridge tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): canonicalize origins (default port + case); share http-scheme check

Post-review polish surfaced by the round-2 reviewers (the substantive loop had
already converged — all three models reported no new must-fix):

- originOf now canonicalizes like a WHATWG browser origin: lowercase scheme +
  host and omit the default port (443/https, 80/http). The WebView reports an
  origin with the default port stripped, so a user who typed `https://host:443`
  previously got pinnedOrigin="https://host:443" that never matched the page's
  "https://host" — breaking the bridge / looping login. Both the pinned origin
  and every page URL flow through originOf, so they canonicalize identically.
  (Gemini flagged this as a pre-existing latent edge.)

- Extract the duplicated http/https scheme test into isHttpScheme() and use it
  at all three sites (originOf-adjacent normalizeServerUrl + both WebViewClient
  nav gates). (Simplifier FYI.)

Build + lint green (0 errors); 32/32 web bridge tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): make isHttpScheme normalize case internally

Round-3 review nit (Codex): isHttpScheme gates a security boundary — which
navigations load in the bridged WebView vs. trigger login / hand off to the
system — but relied on an implicit "callers pass an already-lowercased scheme"
contract. A future caller passing a raw Uri.scheme ("HTTPS") would silently
fail to match. Lowercase internally so the predicate is self-contained; idempotent
and behavior-identical for the 3 current (already-lowercased) call sites.

All 3 round-3 reviewers (Codex/Gemini/Opus) confirmed the loop converged with no
new must-fix; this is the one accepted LOW hardening. Build + lint green (0
errors); 32/32 web bridge tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): server-independent, IME-aware safe-area insets

The shell pins to a server whose web build may predate it, so it can't rely on
the bundle's own inset rules. emitInsets now feeds the app's existing
--omnigent-safe-top/bottom vars (which every build lays out from) alongside
--omnigent-android-safe-area-*, and the bridge injects a <style> that re-asserts
the inset paddings with !important — the server's semantic inset rules otherwise
lose the CSS cascade to the Tailwind utility classes on the same elements, so the
OS inset was dropped (content under the status bar, the chat/terminal switcher
behind the gesture nav). The bottom inset is IME-aware
(max(0, systemBars.bottom - ime.bottom)) so the composer sits flush to the soft
keyboard, not a nav-bar height above it.

Reviewed via a 3-model adversarial loop (Codex/Gemini/Opus) + code-simplifier,
converged clean. Build + lint green; injected bridge JS syntax-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(android): system-back dismisses in-page overlays + clears login history

Android system back was leaving the app / doing nothing / landing on stale pages.
Back now first asks the page to dismiss an open in-page overlay:
- Detects an open sidebar drawer, modal dialog, or panel drawer via
  data-state="open" + an on-screen (center-in-viewport) test, so the panel
  drawers — which stay in the DOM at full size when closed, translated
  off-screen — no longer false-match and swallow the press.
- Gated to the <768 drawer width: at md+ the side surfaces dock as persistent
  rails that back must not close.
- Closes via the overlay's own Close control, else a single Escape (one per
  back, so stacked overlays don't collapse together).

If nothing was open, back navigates WebView history / leaves the app.
clearHistory() drops the pre-auth + login-redirect entries on the first
authenticated load (re-armed on each re-login) so back can't walk into the IdP
redirect or a blank page. The handler is async but races a 600ms timeout
fallback (guarded against a torn-down host) so a back press always acts even if
the renderer is unresponsive.

Reviewed via a 3-model adversarial loop (Codex/Gemini/Opus) + code-simplifier,
converged clean over 2 rounds. Build + lint green; injected bridge JS
syntax-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): themed icon eyes — eyeball + pupil + highlight, both starfish

The monochrome (themed) launcher icon rendered the eyes as hollow holes. A
single-tint icon can't reproduce the full-color icon's white-eyeball/dark-pupil,
but it can read as eyes-with-pupils: cut the eyeball as a hole, fill a tinted
pupil dot inside it, and cut a small highlight glint in the pupil — matching the
standard icon's sparkle. The baby starfish gets the same treatment, separated
from the mama by a thin moat so both read as distinct faces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): don't burn the login retry budget on re-entrant OIDC redirects

A multi-hop OIDC redirect can re-enter startLogin() before the first
browser hand-off settles. start() no-ops via compareAndSet when a login
is already in flight, but loginAttempts++ (and the one-shot history-clear
re-arm) ran unconditionally beforehand — so a 2-3 hop bounce could
exhaust MAX_LOGIN_ATTEMPTS without ever relaunching, suppressing a
legitimate later retry.

Make OidcLoginManager.start() return whether it actually began a flow,
and count / re-arm only on a real launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): harden against off-device session leak and unusable download names

- allowBackup=false: the WebView cookie store holds the authenticated
  __Host-ap_session cookie, so cloud Auto Backup / adb backup would
  otherwise copy a live session off-device. A server URL is trivially
  re-entered; a session is not worth exfiltrating.
- BlobSaver.safeFileName: ""/"."/".." now fall back to a timestamped
  name — the API 28 File path resolves "."/".." to a directory, which
  would fail the write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(android): drop stale ProGuard keep rule for a non-existent class

The rule kept ai.omnigent.android.NativeBridge with @JavascriptInterface
members, but no such class exists and @JavascriptInterface is used
nowhere — the bridge is OmnigentBridgeListener : WebViewCompat.WebMessageListener,
kept via ordinary R8 reachability plus androidx.webkit's consumer rules.
Replace with an accurate note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): lift bottom-anchored content above the soft keyboard

Edge-to-edge (setDecorFitsSystemWindows=false) neutralizes the manifest's
adjustResize, so when the IME opens the window doesn't shrink and bottom-
anchored web content (a chat composer, a terminal input) sat BEHIND the
keyboard. The inset listener now resizes the WebView's laid-out HEIGHT by the
IME inset — a bottom margin, not padding: 100vh / the visual viewport that
fixed/sticky content anchors to tracks the view height, not its content box,
so padding alone wouldn't reflow the composer. The status/nav bars stay CSS
safe-areas so content still draws behind them when the keyboard is hidden.

Verified on an API-34 emulator (CDP: window.innerHeight and visualViewport
shrink 915->578 on IME open; a position:fixed;bottom:0 element rises to the
keyboard's top edge) and on a physical Pixel 10 Pro Fold in a real chat
composer and terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(android): satisfy web format/line-ending hooks on shell files

CI's `npm run format:check` and pre-commit hooks flagged files the Android
shell added:

- README.md: Prettier normalizes `*shell*` -> `_shell_` (markdown emphasis).
- .prettierignore: exclude the Android Gradle build output, mirroring the
  existing `ios/build/` entry — Gradle writes HTML lint reports that Prettier
  would otherwise choke on during a local `--check`.
- ic_launcher_foreground.xml, omnigents_logo.xml: add the trailing newline
  end-of-file-fixer requires.
- gradlew.bat: normalize CRLF -> LF for mixed-line-ending (--fix=lf); the repo
  enforces LF everywhere and has no CRLF-preserving .gitattributes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e-ui): cover the Android shell's web-side detection + safe-area fold

The Android WebView shell injects window.omnigentNative = {kind:"android"}; the
web layer feature-detects it (isAndroidShell) and tags AppShell with
data-android-native, which gates the [data-android-native] chrome in index.css —
notably the safe-area max() fold that lets the OS inset (injected as
--omnigent-android-safe-area-*) reach --omnigent-safe-*.

Mirror the desktop shell tests (sessions/test_pinned_session_hotkeys.py): inject
the bridge via add_init_script and assert data-android-native plus the resolved
inset fold, with a paired plain-browser negative test proving the gate is
Android-only. Covers the web/** change end-to-end — the chain the nativeBridge
unit tests can't reach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): harden review-flagged edge paths in auth, downloads, and tap routing

Addresses the non-blocking findings from the Polly review pass:

- OidcLoginManager: accept only a rooted relative login_url from
  /auth/cli-login (the server always returns "/auth/login?ticket=..."),
  so a hostile/malformed absolute or scheme-relative value can't send the
  one-time ticket flow off the pinned origin.
- MainActivity.onSessionToken: bail when the cookie injection is rejected
  instead of reloading unauthenticated, which re-launched the browser and
  burned the capped login retries on a failure retrying can't fix.
- MainActivity.downloadFile: gate on isHttpScheme(Uri.parse(url).scheme)
  like the navigation gate — accepts "HTTPS://", rejects "httpfoo:" values
  that DownloadManager.Request would throw on.
- MainActivity.flushPendingActivation: keep a notification tap pending when
  the WebView is parked off-origin (mid re-login) rather than emitting into
  a bridgeless page and dropping the path; the next pinned-origin
  onPageReady flushes it.
- BlobSaver.safeFileName: take the basename past backslashes too, so a
  Windows-flavored suggestion saves as "bar.txt" instead of "foo_bar.txt".

assembleDebug + lintDebug green; each change adversarially reviewed against
its call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:36:09 +00:00

135 lines
5.9 KiB
Python

"""Android WebView shell: web-layer feature detection and the safe-area fold.
The native Android shell (``web/android``) loads the SPA and injects
``window.omnigentNative = {kind: "android", ...}``. The web layer feature-detects
it (``isAndroidShell()`` in ``web/src/lib/nativeBridge.ts``) and, when true, the
``AppShell`` tags its root with ``data-android-native="true"``
(``web/src/shell/AppShell.tsx``). That attribute gates the Android-specific
chrome in ``index.css`` — most importantly the safe-area fold: unlike iOS,
Android WebView reports ``env(safe-area-inset-*)`` as 0, so the shell injects the
OS-measured inset as ``--omnigent-android-safe-area-*`` and ``index.css`` folds
it into the shared ``--omnigent-safe-top/bottom`` with ``max()``.
The e2e_ui harness runs the SPA in a plain Chromium browser, not the Android
WebView, so ``isAndroidShell()`` is false by default. To exercise the shell path
end-to-end we inject a minimal ``window.omnigentNative`` stub via
``add_init_script`` *before any app script runs* — the same feature-detection
stubbing the desktop shell tests use (``sessions/test_pinned_session_hotkeys.py``
injects ``window.omnigentDesktop``).
These cover the chain the ``nativeBridge`` unit tests can't reach end to end:
the injected bridge -> ``isAndroidShell()`` -> the ``AppShell``
``data-android-native`` attribute -> the ``index.css`` ``max()`` fold that lets
the injected OS inset reach the layout's shared vars.
"""
from __future__ import annotations
from playwright.sync_api import Page, ViewportSize, expect
# A phone-sized viewport: the Android shell is a mobile surface, and the narrow
# width is where the sidebar behaves as an overlay drawer (the
# ``[data-android-native]`` drawer rules this change adds). The
# ``data-android-native`` tag itself is viewport-independent.
_MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
# Minimal stand-in for the Android WebView bridge (``web/android``'s
# ``NativeBridgeScript``). Runs before any app script on every navigation
# (``add_init_script``), so ``nativeApi()`` in ``nativeBridge.ts`` — which now
# accepts ``kind === "android"`` — sees a native shell. Every method is a guarded
# no-op: ``kind`` is what ``isAndroidShell()`` keys off, and the rest keep
# unrelated native calls (badge / notify / inset subscription) from throwing
# under the stub.
_ANDROID_SHELL_INIT_SCRIPT = """
window.omnigentNative = {
kind: "android",
setBadgeCount: function () {},
notify: function () { return Promise.resolve(false); },
onNotificationActivated: function () { return function () {}; },
onNativeInsets: function () { return function () {}; },
};
"""
# Read the *resolved* ``--omnigent-safe-top`` in pixels. ``getComputedStyle`` on a
# custom property returns its declared text (the ``max()`` expression), so instead
# size a throwaway probe by ``var(--omnigent-safe-top)`` and read its computed
# height, which resolves the fold.
_READ_SAFE_TOP_PX = """
() => {
const probe = document.createElement('div');
probe.style.cssText =
'position:absolute;visibility:hidden;pointer-events:none;height:var(--omnigent-safe-top)';
document.body.appendChild(probe);
const px = getComputedStyle(probe).height;
probe.remove();
return px;
}
"""
def test_android_shell_tags_root_and_folds_os_inset(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Under the injected Android bridge, the SPA tags its root and folds the inset.
Asserts (1) the app-shell carries ``data-android-native="true"`` — i.e.
``isAndroidShell()`` -> ``AppShell`` wiring fires — and (2) the OS inset the
shell injects as ``--omnigent-android-safe-area-top`` reaches the shared
``--omnigent-safe-top`` through the ``index.css`` ``max()`` fold (which is 0
in a plain browser, where ``env(safe-area-inset-top)`` is also 0).
:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session
page.set_viewport_size(_MOBILE_VIEWPORT)
page.add_init_script(_ANDROID_SHELL_INIT_SCRIPT)
page.goto(f"{base_url}/c/{session_id}")
shell = page.locator(".app-shell")
expect(shell).to_have_attribute("data-android-native", "true")
# No native inset injected yet: env(safe-area-inset-top) is 0 in a plain
# browser and the Android var is unset, so the fold resolves to 0.
assert page.evaluate(_READ_SAFE_TOP_PX) == "0px"
# The native layer pushes the measured OS inset as
# --omnigent-android-safe-area-top; index.css folds it into
# --omnigent-safe-top via max(), so the layout reads the real inset.
page.evaluate(
"() => document.documentElement.style"
".setProperty('--omnigent-android-safe-area-top', '40px')"
)
assert page.evaluate(_READ_SAFE_TOP_PX) == "40px"
def test_no_android_tag_or_fold_in_plain_browser(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A plain browser tab (no bridge) gets neither the tag nor the fold.
Without the ``window.omnigentNative`` stub, ``isAndroidShell()`` is false, so
the app-shell must NOT carry ``data-android-native`` and the
``--omnigent-android-safe-area-*`` fold must contribute nothing — the gate
that keeps the Android chrome off the plain web app. This is the half of the
contract only an end-to-end browser run can prove.
:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session
page.set_viewport_size(_MOBILE_VIEWPORT)
page.goto(f"{base_url}/c/{session_id}")
shell = page.locator(".app-shell")
expect(shell).to_be_visible()
assert shell.get_attribute("data-android-native") is None
# The web app never injects --omnigent-android-safe-area-*, so with
# env(safe-area-inset-top) also 0 here the shared inset stays 0.
assert page.evaluate(_READ_SAFE_TOP_PX) == "0px"