Build the WebView

Load an OmniLab experience in a React Native, iOS or Android WebView, with the settings that matter.

4 min read

For your mobile developer. Confirm the WebView requirements first, and test with the Mobile integration checklist when you're done.

Keep sign-in inside the WebView

Most apps send links that leave the experience to the phone's browser. Sign-in also leaves the experience domain, so a guard that only knows that domain breaks it. Keep these hosts in the WebView:

HostExample
Your experience domainplay.lindenhall.example
The sign-in step: your default subdomain, then .api.topage.co, or .api.uat.topage.co on Staginglindenhall.api.topage.co
Your identity provider's sign-in pagesid.lindenhall.example

During a test sign-in, log every address the guard sees. Add any other host the round trip passes through, then open everything else in the browser.

React Native

npm install react-native-webview
OmniLab in a React Native WebView
import React from "react";
import { Linking } from "react-native";
import { WebView } from "react-native-webview";
import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes";

const EXPERIENCE_URL = "https://play.lindenhall.example/lindenhall-autumn-workshops?c=a315";

// The experience, then every host its sign-in passes through.
const IN_APP_HOSTS = ["play.lindenhall.example", "lindenhall.api.topage.co", "id.lindenhall.example"];

type OmniLabScreenProps = {
  language: string;
  memberId?: string;
};

function hostOf(url: string): string {
  const match = /^https?:\/\/([^/?#:]+)/i.exec(url);
  return match ? match[1].toLowerCase() : "";
}

export default function OmniLabScreen({ language, memberId }: OmniLabScreenProps) {
  const uri =
    EXPERIENCE_URL +
    `&l=${encodeURIComponent(language)}` +
    (memberId ? `&fci=${encodeURIComponent(memberId)}` : "");

  const keepInApp = (request: ShouldStartLoadRequest): boolean => {
    // iOS reports frames inside the page, such as a video: let them load.
    if (request.isTopFrame === false) return true;
    // Files the page creates itself, such as a calendar file.
    if (/^(blob|data|about):/i.test(request.url)) return true;
    if (IN_APP_HOSTS.includes(hostOf(request.url))) return true;
    // Anything else, such as a sponsor's site or the terms, opens in the browser.
    Linking.openURL(request.url).catch(() => {});
    return false;
  };

  return (
    <WebView
      source={{ uri }}
      style={{ flex: 1 }}
      geolocationEnabled
      allowsInlineMediaPlayback
      mediaPlaybackRequiresUserAction={false}
      mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"
      onShouldStartLoadWithRequest={keepInApp}
    />
  );
}

What the settings do:

  • flex: 1: without it, the WebView collapses to zero height and shows nothing, which reads as a loading failure.
  • geolocationEnabled: turns location on for Android, where it's off by default. Treasure hunts and location checks need it.
  • allowsInlineMediaPlayback: lets the camera preview of a QR scan play inside the page on iOS.
  • mediaCapturePermissionGrantType: on iOS 15 and later, the experience uses the camera without a second prompt from the page.
  • onShouldStartLoadWithRequest: applies the host list above. External links open in the browser, where the participant has a way back.

The library opens the system file picker itself, for receipt uploads and photo fields.

Native iOS

Use WKWebView:

  • Set allowsInlineMediaPlayback to true on its configuration, so a QR scan's camera preview plays inside the page.
  • Implement webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:) to grant the camera to your experience domain. Otherwise WebKit adds its own prompt on top of the app's.
  • In webView(_:decidePolicyFor:decisionHandler:), allow frames that aren't the main frame, and the hosts above. Open anything else with UIApplication.shared.open, then cancel.
  • Implement webView(_:createWebViewWith:for:windowFeatures:). The experience opens its terms and some links in a new window, which WKWebView ignores without it.

Native Android

Use the standard WebView:

  • Turn on javaScriptEnabled and domStorageEnabled in its settings. Both are off by default.
  • In your WebChromeClient, implement onPermissionRequest. Grant the camera after checking the app's own runtime permission: the page prompt and the Android permission are two separate gates.
  • Implement onShowFileChooser, and return the chosen file. Without it, receipt uploads and photo fields do nothing.
  • Implement onGeolocationPermissionsShowPrompt, and call callback.invoke(origin, true, false) once the app has location permission. Without it, location checks fail.
  • In your WebViewClient, apply the host list in shouldOverrideUrlLoading. Open anything else with an ACTION_VIEW intent.
  • Call setMediaPlaybackRequiresUserGesture(false) in its settings, so media the experience starts itself, such as the camera preview, can play.

Declare the permissions

FeatureiOS Info.plistAndroid manifest
CameraNSCameraUsageDescriptionandroid.permission.CAMERA
Photo library, for uploadsNSPhotoLibraryUsageDescriptionNone for the system picker
LocationNSLocationWhenInUseUsageDescriptionandroid.permission.ACCESS_FINE_LOCATION

A missing iOS usage description terminates the app the first time the permission is requested.

Save downloads

A booking's calendar file or ticket, and a photo the participant saves, are files the page creates itself. The page offers each one as a download link, and releases that link straight after the tap. A WebView doesn't save such a file by itself, and a download handler that fetches the link afterwards gets nothing. Capture the file inside the page instead:

Injected into the experience page by your app
(function () {
  if (window.__omnilabDownloadBridge) return;
  window.__omnilabDownloadBridge = true;

  function forward(message) {
    const json = JSON.stringify(message);
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(json); // React Native
    } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.omnilab) {
      window.webkit.messageHandlers.omnilab.postMessage(json); // iOS WKWebView
    } else if (window.OmnilabBridge) {
      window.OmnilabBridge.postMessage(json); // Android WebView
    }
  }

  // Keep each file the page creates until it releases the link, so a tap can still read it.
  const files = new Map();
  const createObjectURL = URL.createObjectURL.bind(URL);
  const revokeObjectURL = URL.revokeObjectURL.bind(URL);
  URL.createObjectURL = function (object) {
    const url = createObjectURL(object);
    if (object instanceof Blob) files.set(url, object);
    return url;
  };
  URL.revokeObjectURL = function (url) {
    files.delete(url);
    revokeObjectURL(url);
  };

  document.addEventListener("click", (event) => {
    const link = event.target instanceof Element ? event.target.closest("a[download]") : null;
    const file = link ? files.get(link.href) : null;
    if (!file) return;
    event.preventDefault();
    const reader = new FileReader();
    reader.onload = () =>
      forward({ type: "omnilab:download", filename: link.download, mimeType: file.type, dataUrl: reader.result });
    reader.readAsDataURL(file);
  }, true);
})();
true;
PlatformInject the script and receive the file withThen
React NativeThe injectedJavaScript prop, and onMessage, in event.nativeEvent.dataWrite dataUrl to a file named filename with your file-system library, then open the share sheet
iOS WKWebViewA WKUserScript at document end, main frame only, and a WKScriptMessageHandler named omnilabDecode dataUrl into Data, write it to a temporary file named filename, then present a UIActivityViewController
Android WebViewevaluateJavascript in onPageFinished, and addJavascriptInterface named OmnilabBridge, with a @JavascriptInterface method postMessage(String)Decode the Base64 part of dataUrl, then save it with MediaStore or share it with an ACTION_SEND intent

Pass your signed-in member through

If your app knows who the participant is, add fci to the address. Whether they then skip the sign-in screen depends on the campaign: see What your app should add.

Keep credentials out of the app bundle

If your app also needs server-to-server access to OmniLab, do it from your own backend. Anything shipped in an app bundle can be extracted from it.

Next steps

On this page