Build the WebView
Load an OmniLab experience in a React Native, iOS or Android WebView, with the settings that matter.
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:
| Host | Example |
|---|---|
| Your experience domain | play.lindenhall.example |
The sign-in step: your default subdomain, then .api.topage.co, or .api.uat.topage.co on Staging | lindenhall.api.topage.co |
| Your identity provider's sign-in pages | id.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-webviewimport 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
allowsInlineMediaPlaybacktotrueon 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 withUIApplication.shared.open, then cancel. - Implement
webView(_:createWebViewWith:for:windowFeatures:). The experience opens its terms and some links in a new window, whichWKWebViewignores without it.
Native Android
Use the standard WebView:
- Turn on
javaScriptEnabledanddomStorageEnabledin its settings. Both are off by default. - In your
WebChromeClient, implementonPermissionRequest. 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 callcallback.invoke(origin, true, false)once the app has location permission. Without it, location checks fail. - In your
WebViewClient, apply the host list inshouldOverrideUrlLoading. Open anything else with anACTION_VIEWintent. - Call
setMediaPlaybackRequiresUserGesture(false)in its settings, so media the experience starts itself, such as the camera preview, can play.
Declare the permissions
| Feature | iOS Info.plist | Android manifest |
|---|---|---|
| Camera | NSCameraUsageDescription | android.permission.CAMERA |
| Photo library, for uploads | NSPhotoLibraryUsageDescription | None for the system picker |
| Location | NSLocationWhenInUseUsageDescription | android.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:
(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;| Platform | Inject the script and receive the file with | Then |
|---|---|---|
| React Native | The injectedJavaScript prop, and onMessage, in event.nativeEvent.data | Write dataUrl to a file named filename with your file-system library, then open the share sheet |
iOS WKWebView | A WKUserScript at document end, main frame only, and a WKScriptMessageHandler named omnilab | Decode dataUrl into Data, write it to a temporary file named filename, then present a UIActivityViewController |
Android WebView | evaluateJavascript 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.