a4ef23f71e
* 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>
135 lines
5.9 KiB
Python
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"
|