"""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"