Skip to content

Ask the docs

Answers come from this documentation only, with sources. For account-specific issues, the docs route you to the right owner.

What can I help you find?

You are chatting with an AI assistant. It can make mistakes, so double-check important information.

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.

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

The 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.

  • androidx.webkit 1.3 or later, for WebViewCompat.addWebMessageListener. Check WebViewFeature.WEB_MESSAGE_LISTENER at runtime.
  • androidx.browser, to open the checkout link in a Custom Tab.
  • The CAMERA permission in your manifest, requested at runtime, if the advisor scans in the app.
private const val DIALOG_ORIGIN = "https://assistant-app.askdialog.com"
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.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.

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
}
}
}
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.
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.

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

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()
}
}
}