Embedded app on Android
On Android, your advisor app loads the embedded app in a WebView instead of an iframe. The app talks to your Kotlin code through a bridge you inject, with the same messages as the web version plus what a phone needs: the system back button, links to open outside the app, and a scanner your app runs when the WebView cannot scan.
Everything else is unchanged: the dashboard setup, the signing keys and the advisor token your backend signs.
How it fits together
Section titled “How it fits together”Your Android app (WebView) Dialog loadUrl ────────────────────────────────▶ assistant app ◀── dialog:ready ──▶ dialog:auth { token } (signed by your backend) ──▶ dialog:shopper { email } ──▶ dialog:back (system back button) ◀── dialog:back-result { handled } ◀── dialog:scan-requested (the WebView cannot scan) ──▶ dialog:open-product { productId } ◀── dialog:open-url { url } (the checkout link) ◀── dialog:pre-cart-sentThe app loads at the top of the WebView, not inside an iframe, so there is no allowed origin to declare in the dashboard for Android. Your app restricts the bridge to Dialog’s origin instead.
Requirements
Section titled “Requirements”androidx.webkit1.3 or later, forWebViewCompat.addWebMessageListener. CheckWebViewFeature.WEB_MESSAGE_LISTENERat runtime.androidx.browser, to open the checkout link in a Custom Tab.- The
CAMERApermission in your manifest, requested at runtime, if the advisor scans in the app.
Load the app
Section titled “Load the app”private const val DIALOG_ORIGIN = "https://assistant-app.askdialog.com"
webView.settings.javaScriptEnabled = truewebView.settings.domStorageEnabled = truewebView.settings.mediaPlaybackRequiresUserGesture = false
// Without the bridge the app cannot get its token: say so instead of loading it.if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { showWebViewUpdateRequired() return}
// Before loadUrl: the bridge is injected when the page loads.WebViewCompat.addWebMessageListener(webView, "DialogHost", setOf(DIALOG_ORIGIN)) { _, message, _, _, replyProxy -> dialogHost = replyProxy message.data?.let { onDialogMessage(JSONObject(it)) }}
webView.loadUrl("$DIALOG_ORIGIN/?apiKey=YOUR_PUBLIC_API_KEY&locale=fr-FR&scan=native")The bridge must be named DialogHost, and its origin rule must be exactly Dialog’s origin: the bridge then exists only in Dialog’s page, never in a page a link may lead to. Messages are JSON text both ways.
| Parameter | Required | Description |
|---|---|---|
apiKey |
yes | Your public API key, as on the web. |
locale |
no | A BCP 47 locale (fr-FR, en-GB). Defaults to the device’s. |
countryCode |
no | Two-letter country, when the market differs from the locale’s region. |
scan |
no | native when your app can scan codes itself. See Scanning. |
Messages
Section titled “Messages”Your app can only answer after the app’s first message: the replyProxy comes with dialog:ready.
private var dialogHost: JavaScriptReplyProxy? = null
private fun send(type: String, vararg fields: Pair<String, Any>) { val message = JSONObject().put("type", type) fields.forEach { (key, value) -> message.put(key, value) } dialogHost?.postMessage(message.toString())}
private fun onDialogMessage(message: JSONObject) { when (message.getString("type")) { "dialog:ready", "dialog:token-expiring" -> lifecycleScope.launch { send("dialog:auth", "token" to backend.fetchDialogToken()) } "dialog:back-result" -> if (!message.getBoolean("handled")) finish() "dialog:open-url" -> CustomTabsIntent.Builder().build() .launchUrl(this, Uri.parse(message.getString("url"))) "dialog:scan-requested" -> scanner.scan { code -> send("dialog:open-product", "productId" to code) } "dialog:pre-cart-sent" -> { // draftOrderName, invoiceUrl, invoiceSent } }}From the app to your code
Section titled “From the app to your code”| Message | Payload | When |
|---|---|---|
dialog:ready |
none | The app is loaded. Answer with dialog:auth, then dialog:shopper if known. |
dialog:token-expiring |
none | About 60 seconds before the token expires, or after Dialog refused it. Answer with a fresh dialog:auth. |
dialog:back-result |
{ handled } |
The answer to dialog:back. false on the app’s home screen: close the screen yourself. |
dialog:open-url |
{ url } |
A link the app cannot open: a WebView would load it in place of the app. Today, the checkout link of a sent pre-cart. Open it in a Custom Tab. |
dialog:scan-requested |
none | The advisor asked to scan and the WebView cannot. Scan, then answer with dialog:open-product. |
dialog:pre-cart-sent |
{ draftOrderName, invoiceUrl, invoiceSent } |
As on the web. |
From your code to the app
Section titled “From your code to the app”| Message | Payload | Role |
|---|---|---|
dialog:auth |
{ token } |
The advisor token, as on the web. |
dialog:shopper |
{ email?, customerId? } |
The customer the advisor is serving, as on the web. |
dialog:back |
none | The system back button. The app closes its open sheet or goes back a screen, then answers with dialog:back-result. |
dialog:open-product |
{ productId } |
Opens the assistant on a product: the code your scanner read, or a product your app chose. |
The back button
Section titled “The back button”Send every back press to the app, and leave the screen only when it says it had nothing to go back to.
onBackPressedDispatcher.addCallback(this) { if (dialogHost == null) finish() else send("dialog:back")}Scanning
Section titled “Scanning”The app scans QR codes with the camera when the WebView supports it. Not every Android WebView does, depending on the device. With scan=native in the URL, the app falls back on your scanner when it cannot scan, or when the advisor refused the camera: it sends dialog:scan-requested, and you answer with dialog:open-product. Use any scanner, for example ML Kit’s barcode scanning.
The QR code holds the product id, as in the web version. Advisors can always type the code instead.
For the scanner in the WebView, grant the camera to Dialog’s page once your app holds the CAMERA permission:
webView.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { val fromDialog = request.origin.toString().trimEnd('/') == DIALOG_ORIGIN if (fromDialog && PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) { request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) } else { request.deny() } }}Next steps
Section titled “Next steps”- Embedded app: the dashboard setup and the advisor token.
- Catalog integration: the catalog the embedded app browses.
