You can show the Pleased Chat Widget inside your Android app. Your app opens a screen with a WebView, and the WebView loads a page that Pleased hosts for you. You do not need a website of your own.
This article is for the Android developer adding the widget. It covers the chat itself, signing in your users, file attachments and voice calls.
Before you begin
- An Android project with
minSdk21 or higher. You do not need any extra libraries or Gradle changes. - A Chat Widget created in Pleased. See Install the Pleased Chat Widget on your website for how to create one.
- Your client ID and widget ID. You will find them in the Chat Widget code that Pleased generates: they are the values of
data-pls-client-idanddata-pls-widget-id.
Important: Use the IDs from the code generated for your Chat Widget in Pleased. Do not copy the example values from this article.
Step 1: Add the permissions
Add these three permissions to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
INTERNET loads the chat. RECORD_AUDIO and MODIFY_AUDIO_SETTINGS are for voice calls.
Important: Do not leave outMODIFY_AUDIO_SETTINGS. Android grants it at install time, so it never appears in a prompt and nothing warns you when it is missing. Without it, the visitor allows the microphone and the call still fails withNotReadableError: Could not start audio source.
If you will never use voice calls, you can leave out the two audio permissions.
Step 2: Add the two Kotlin files
Copy both files into your project as they are, in the package com.pleased.client.
PleasedWebViewSetup.kt
Sets up the WebView, answers microphone requests and opens the system file picker for attachments.
package com.pleased.client
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.webkit.ConsoleMessage
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* WebView setup for the Pleased chatbot. Copy this file as-is.
*
* ```
* private lateinit var pleased: PleasedWebViewSetup
*
* pleased = PleasedWebViewSetup(this, webView).apply { configure() }
* webView.loadUrl(hostPageUrl)
*
* override fun onRequestPermissionsResult(code: Int, perms: Array<out String>, results: IntArray) {
* super.onRequestPermissionsResult(code, perms, results)
* pleased.onRequestPermissionsResult(code, results)
* }
*
* override fun onActivityResult(code: Int, result: Int, data: Intent?) {
* super.onActivityResult(code, result, data)
* pleased.onActivityResult(code, result, data) // the chat's attach-file button
* }
* ```
*
* Add BOTH permissions to your manifest:
* ```
* <uses-permission android:name="android.permission.RECORD_AUDIO" />
* <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
* ```
*
* `MODIFY_AUDIO_SETTINGS` is not optional. It is install-time, so it never appears in a prompt,
* and without it `getUserMedia` fails with `NotReadableError` even once RECORD_AUDIO is granted.
*
* One instance per WebView. Depends on framework API only.
*
* @param widgetOrigins origins allowed to use the microphone, besides the page you load. The
* chatbot asks from the widget's own iframe, so this must contain wherever the widget is
* served from. Override it if you self-host the loader.
*/
class PleasedWebViewSetup(
private val activity: Activity,
private val webView: WebView,
private val widgetOrigins: Set<String> = DEFAULT_WIDGET_ORIGINS,
) {
/**
* The page this WebView is for, pinned once its first load has FINISHED — so the whole
* redirect chain of that first navigation is allowed, and everything after is measured
* against where you actually ended up. Compared without the fragment, which never loads a
* page.
*
* The URL, not just the origin: origin-only would let a link to a neighbouring path on the
* same host keep the bridge and the microphone handler, which matters as soon as your host
* page shares an origin with anything you do not control.
*/
private var pinnedUrl: String? = null
private var pendingRequest: PermissionRequest? = null
private var pendingFiles: ValueCallback<Array<Uri>>? = null
fun configure() {
// chrome://inspect, in debug builds only — it exposes the page to anything that can
// reach the device.
if ((activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
WebView.setWebContentsDebuggingEnabled(true)
}
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Without this the WebView can send audio but never play any back.
mediaPlaybackRequiresUserGesture = false
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
// The first load has settled, redirects included. That is the page.
if (pinnedUrl == null) pinnedUrl = withoutFragment(url)
}
/**
* The backstop, and not redundant with [shouldOverrideUrlLoading]: Android does not
* consult that method for every main-frame navigation — a POST, or some redirects,
* arrive here having never been offered for approval.
*/
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
val pinned = pinnedUrl ?: return
if (withoutFragment(url) == pinned) return
view.stopLoading()
leaveWebView(url)
}
/**
* Keeps this WebView on the page you loaded, because it is not an ordinary WebView:
* it carries a JavaScript bridge that reaches your token callback, and it answers
* microphone requests. Off-site navigation goes to the browser, which has neither.
*/
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
// Sub-frames are the widget's iframe and what it loads, never a navigation of
// this WebView.
if (!request.isForMainFrame) return false
val pinned = pinnedUrl ?: return false
val target = request.url?.toString() ?: return true
if (withoutFragment(target) == pinned) return false
leaveWebView(target)
return true
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(message: ConsoleMessage): Boolean = false
override fun onPermissionRequest(request: PermissionRequest) {
// Not overriding this is not a neutral choice: the base implementation denies,
// with no prompt and no log.
answerAudioPermission(request)
}
override fun onPermissionRequestCanceled(request: PermissionRequest) {
if (pendingRequest == request) pendingRequest = null
}
/**
* The chat's attach-file button is an `<input type="file">`. A WebView has no picker
* of its own: the base implementation returns false and the tap does nothing — no
* error, no log. iOS needs no equivalent; WKWebView shows its own picker.
*/
override fun onShowFileChooser(
view: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
params: FileChooserParams,
): Boolean {
// One chooser at a time. An older callback left unanswered keeps that input
// dead until the page reloads, so release it first.
pendingFiles?.onReceiveValue(null)
pendingFiles = filePathCallback
return try {
activity.startActivityForResult(params.createIntent(), REQUEST_CODE_FILE_CHOOSER)
true
} catch (e: ActivityNotFoundException) {
// Returning false cancels on the WebView's side; the callback must then
// stay unanswered.
pendingFiles = null
false
}
}
}
}
/**
* Call from your Activity's `onActivityResult`. Returns true if this was the file picker
* this WebView opened.
*/
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST_CODE_FILE_CHOOSER) return false
val callback = pendingFiles ?: return false
pendingFiles = null
// Always answer, a cancel included (null) — see onShowFileChooser.
callback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data))
return true
}
/**
* Call from your Activity's `onRequestPermissionsResult`. Returns true if this WebView was
* the one waiting, so an Activity with more than one can offer the result to each.
*/
fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray): Boolean {
if (requestCode != REQUEST_CODE_RECORD_AUDIO) return false
val request = pendingRequest ?: return false
pendingRequest = null
val granted = grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) request.grant(AUDIO_ONLY) else request.deny()
return true
}
internal fun answerAudioPermission(request: PermissionRequest) {
// This origin is the frame that asked — for the chatbot, the widget's iframe, NOT the
// page you loaded. Comparing it to your own URL refuses every working integration.
//
// A request with no identifiable origin is refused outright. Letting it reach the
// comparison would grant it whenever nothing is pinned yet, since null equals null.
val origin = originOf(request.origin?.toString())
if (origin == null || (origin != originOf(pinnedUrl) && origin !in widgetOrigins)) {
request.deny()
return
}
if (!request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
request.deny()
return
}
// Runtime permissions arrived in API 23. Below that, a permission in the manifest is
// granted at install time and there is nothing to ask for — and both checkSelfPermission
// and requestPermissions are API 23, so reaching them on an older device throws
// NoSuchMethodError. Without this the whole class needs minSdk 23.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
request.grant(AUDIO_ONLY)
return
}
// Audio only. `request.resources` is whatever the page asked for, so handing it back
// would grant the camera off the strength of a RECORD_AUDIO check.
if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
request.grant(AUDIO_ONLY)
return
}
// Just ask. shouldShowRequestPermissionRationale() cannot tell you whether a prompt will
// appear — it returns false for never-asked, permanently-denied AND "Ask every time" —
// so any pre-check eventually refuses someone the OS would have prompted. A permission
// that really is permanently denied is answered at once, with no UI.
pendingRequest = request
activity.requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_CODE_RECORD_AUDIO)
}
/** Hand a URL to the browser. Failing to launch is not a reason to load it here instead. */
private fun leaveWebView(url: String) {
runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
companion object {
const val REQUEST_CODE_RECORD_AUDIO = 4371
const val REQUEST_CODE_FILE_CHOOSER = 4372
/** Where the Pleased widget is served from — the host of your loader script. */
val DEFAULT_WIDGET_ORIGINS = setOf("https://cdn.pleased.com")
private val AUDIO_ONLY = arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)
/** Fragments never load a page, so they must not count as navigating away. */
private fun withoutFragment(url: String) = url.substringBefore('#')
internal fun originOf(url: String?): String? = url
?.let { runCatching { Uri.parse(it) }.getOrNull() }
?.let { uri -> uri.scheme?.let { scheme -> uri.authority?.let { "$scheme://$it" } } }
}
}
PleasedBridge.kt
Lets your app talk to the widget. Your app uses it to hand over the visitor's token, open the chat and react to events.
package com.pleased.client
import android.webkit.JavascriptInterface
import android.webkit.WebView
import java.util.concurrent.ConcurrentHashMap
import org.json.JSONObject
import org.json.JSONTokener
/**
* Drives the Pleased loader running inside a WebView. Copy this file as-is.
*
* ```
* val bridge = PleasedBridge(webView) // BEFORE loadUrl
* val pleased = PleasedWebViewSetup(this, webView).apply { configure() }
* webView.loadUrl(hostPageUrl)
*
* bridge.onLoaderReady = {
* bridge.registerCallback("set:callback", "token") { _, respond ->
* fetchToken { respond.resolve(JSONObject.quote(it)) } // async is fine
* }
* bridge.registerCallback("event:on", "ready") { _, _ -> bridge.invoke("widget:open", "{}") }
* }
* ```
*
* Commands pass through as strings, so a new `set:` or event needs no change here. The list of
* what you can send is in the integration guide, not duplicated in Kotlin.
*/
class PleasedBridge(private val webView: WebView) {
/**
* Called on the main thread, so it is safe to touch UI, start an Activity or read anything
* with thread affinity straight from here.
*
* Answer through [respond] exactly once. Async is fine — the widget gives up after 10s.
* For `event:on` / `event:once` there is nothing to answer and [respond] does nothing.
*/
fun interface CallbackHandler {
fun handle(payloadJson: String?, respond: Responder)
}
interface Responder {
/** [valueJson] must be valid JSON. A bare string needs `JSONObject.quote(value)`. */
fun resolve(valueJson: String?)
fun reject(error: String)
}
/** Fires once the loader script has run. Register your callbacks here. */
var onLoaderReady: (() -> Unit)? = null
// @JavascriptInterface arrives on a background thread while registerCallback writes from
// the UI thread; a plain HashMap loses registrations under that race.
private val handlers = ConcurrentHashMap<String, CallbackHandler>()
init {
// Must be attached before the page loads, or every callback rejects with
// "no native bridge attached".
webView.addJavascriptInterface(NativeInterface(), "PleasedNative")
}
/**
* Run any loader command. [onResult] gets the JSON `{ok, result}` the page returned, or
* `{ok: false, error}` without reaching the page when [payloadJson] is not JSON.
*/
fun invoke(command: String, payloadJson: String? = null, onResult: ((String) -> Unit)? = null) {
// Caught here rather than by JSON.parse in the page, so the error names the real cause
// and arrives before anything is evaluated. Empty means "no payload", as in the page.
if (!payloadJson.isNullOrEmpty() && runCatching { JSONTokener(payloadJson).nextValue() }.isFailure) {
val error = JSONObject().put("ok", false).put("error", "payloadJson is not valid JSON").toString()
onResult?.let { webView.post { it(error) } }
return
}
val js = "window.__plInvoke(${JSONObject.quote(command)}, " +
(payloadJson?.let { JSONObject.quote(it) } ?: "null") + ")"
eval(js, onResult)
}
/**
* [type] is the loader command verbatim: `set:callback` for values the widget asks for,
* `event:on` / `event:once` to subscribe. Name an event `*` to receive all of them, as
* `{"event":..., "payload":...}`.
*/
fun registerCallback(type: String, name: String, handler: CallbackHandler) {
handlers["$type|$name"] = handler
eval("window.__plAddCallback(${JSONObject.quote(type)}, ${JSONObject.quote(name)})")
}
fun removeCallback(type: String, name: String) {
handlers.remove("$type|$name")
eval("window.__plRemoveCallback(${JSONObject.quote(type)}, ${JSONObject.quote(name)})")
}
private fun eval(js: String, onResult: ((String) -> Unit)? = null) {
// evaluateJavascript must run on the UI thread, and @JavascriptInterface methods are
// delivered on a background one, so this post is load-bearing rather than tidy.
webView.post { webView.evaluateJavascript(js) { result -> onResult?.invoke(unwrap(result)) } }
}
/**
* evaluateJavascript JSON-encodes what the page returned, and the shim returns a JSON
* string — so results arrive twice-encoded and `JSONObject(it)` throws. Undo one layer.
*/
private fun unwrap(raw: String?): String {
val value = raw ?: return "null"
val decoded = runCatching { JSONTokener(value).nextValue() }.getOrNull()
return if (decoded is String) decoded else value
}
/**
* Hands work to the main thread.
*
* `@JavascriptInterface` methods are delivered on a WebView background thread. Running a
* client's handler there would mean their token lookup, their Activity start, their view
* update — all off the UI thread, crashing somewhere far from the cause. Everything the
* page sends in crosses back here first.
*/
private fun onMain(block: () -> Unit) {
webView.post(block)
}
private inner class NativeInterface {
@JavascriptInterface
fun onMessage(json: String) {
val message = runCatching { JSONObject(json) }.getOrNull() ?: return
when (message.optString("kind")) {
"loader_ready" -> onMain { onLoaderReady?.invoke() }
"event" -> {
val name = message.optString("name")
val raw = message.opt("payload")?.takeIf { it != JSONObject.NULL }
// One delivery per subscription, and `via` says which one — so a host
// registered twice for the same event (event:on and event:once, or a name
// and "*") runs each handler once rather than every handler every time.
val via = message.optString("via")
when {
// A wildcard delivery names the event, not the subscription, so it is
// keyed by "*" and the name travels inside an envelope — a wildcard
// handler cannot tell what it received without it. `via` still says
// which subscription, which is what keeps event:on "*" and
// event:once "*" apart.
message.optBoolean("wildcard") -> {
val envelope = JSONObject()
.put("event", name)
.put("payload", raw ?: JSONObject.NULL)
.toString()
onMain { handlers["$via|*"]?.handle(envelope, NoopResponder) }
}
via.isEmpty() -> onMain {
// No `via`: an older host page than this bridge. Fall back to the
// previous behaviour rather than dropping the event.
handlers["event:on|$name"]?.handle(raw?.toString(), NoopResponder)
handlers["event:once|$name"]?.handle(raw?.toString(), NoopResponder)
}
else -> onMain { handlers["$via|$name"]?.handle(raw?.toString(), NoopResponder) }
}
}
"callback" -> {
val name = message.optString("name")
val id = message.optString("id")
val type = message.optString("type")
val payload = message.opt("payload")?.takeIf { it != JSONObject.NULL }?.toString()
val handler = handlers["$type|$name"]
if (handler == null) {
reject(id, "no handler registered for $name")
return
}
onMain { handler.handle(payload, JsResponder(id)) }
}
}
}
}
private fun resolve(id: String, valueJson: String?) {
eval(
"window.__plResolveCallback(${JSONObject.quote(id)}, " +
(valueJson?.let { JSONObject.quote(it) } ?: "null") + ")"
)
}
private fun reject(id: String, error: String) {
eval("window.__plRejectCallback(${JSONObject.quote(id)}, ${JSONObject.quote(error)})")
}
private inner class JsResponder(private val id: String) : Responder {
private var settled = false
override fun resolve(valueJson: String?) {
if (settled) return
settled = true
this@PleasedBridge.resolve(id, valueJson)
}
override fun reject(error: String) {
if (settled) return
settled = true
this@PleasedBridge.reject(id, error)
}
}
private object NoopResponder : Responder {
override fun resolve(valueJson: String?) = Unit
override fun reject(error: String) = Unit
}
}
Step 3: Add the chat screen
Create an Activity for the chat. Replace YOUR_CLIENT_ID and YOUR_WIDGET_ID with the values from your generated code.
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.webkit.WebView
import com.pleased.client.PleasedBridge
import com.pleased.client.PleasedWebViewSetup
import org.json.JSONObject
class ChatActivity : Activity() {
private lateinit var webView: WebView
private lateinit var pleased: PleasedWebViewSetup
private lateinit var bridge: PleasedBridge
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
// Before configure() and before loadUrl: the page looks for the bridge as soon as it runs.
bridge = PleasedBridge(webView)
bridge.onLoaderReady = {
// The widget asks for the signed-in visitor's token. Return "" for a guest.
// fetchTokenFromYourBackend is yours: call your server, which mints the JWE
// (see Step 4). Do the network call off the main thread and call respond
// when it returns. Never mint the token in the app: the key must stay server-side.
bridge.registerCallback("set:callback", "token") { _, respond ->
fetchTokenFromYourBackend { token ->
respond.resolve(JSONObject.quote(token)) // async is fine
}
}
bridge.registerCallback("event:on", "ready") { _, _ ->
bridge.invoke("widget:open", "{}")
}
// Required, not optional — see below.
bridge.registerCallback("event:on", "close") { _, _ -> finish() }
// The way back from a blocked microphone — see "When the microphone is blocked".
bridge.registerCallback("event:on", "request_open_settings") { _, _ ->
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null),
)
)
}
bridge.invoke("set:config", """{"capabilities":{"canOpenAppSettings":true}}""")
}
pleased = PleasedWebViewSetup(this, webView).apply { configure() }
webView.loadUrl(
"https://cdn.pleased.com/widget/native/index.html" +
"?clientId=YOUR_CLIENT_ID&widgetId=YOUR_WIDGET_ID&platform=app"
)
}
override fun onRequestPermissionsResult(
code: Int,
permissions: Array<out String>,
results: IntArray,
) {
super.onRequestPermissionsResult(code, permissions, results)
pleased.onRequestPermissionsResult(code, results)
}
override fun onActivityResult(code: Int, result: Int, data: Intent?) {
super.onActivityResult(code, result, data)
pleased.onActivityResult(code, result, data)
}
}
Register the Activity in AndroidManifest.xml with a theme that has no action bar:
<activity
android:name=".ChatActivity"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
Then open it from wherever your app offers support, for example a Help button:
startActivity(Intent(this, ChatActivity::class.java))
A few parts of this screen are easy to get wrong:
- Use a theme with no action bar. From
targetSdk35 theWebViewfills the whole screen, and an action bar would cover the widget's close button. - Keep
platform=appin the URL. It tells the widget that your app has its own way to open the chat, so the widget does not draw its own chat bubble on top of your screens. - Keep the
closehandler. When the visitor minimises the chat, the widget hides and sendsclose. The screen above callsfinish(), which takes the visitor back to where they came from. Without it they are left looking at an empty screen. - Forward
onRequestPermissionsResult. Without it, the visitor taps Allow on the microphone prompt and nothing happens. - Forward
onActivityResult. Without it, the file picker opens, the visitor picks a file and nothing is attached. - Create
PleasedBridgebefore you callloadUrl. The page looks for it as soon as it loads.
Step 4: Sign in your users
When a visitor is signed in to your app, the widget can know who they are. They see their name and their previous conversations, and they do not have to type their contact details.
The widget asks for a token through the token callback in Step 3. Your app gets the token from your own server and passes it on. For a guest, pass an empty string.
Get the key
- Go to Settings > Chat Widget Setup in Pleased.
- Open Widget Authentication.
- Select Reveal to show the key.
Important: Keep the key on your server and never put it in your app. Anyone who extracts it from the app could sign in to the chat as any of your users. If the key leaks, generate a new one on the same page. The old key stops working straight away, so update your server at the same time.
Create the token on your server
Add an endpoint to your server that creates a token for the signed-in user. The token is a JWE encrypted with the key, using RSA-OAEP-256 and A128GCM. This Java example uses the Nimbus JOSE + JWT library:
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import com.nimbusds.jwt.EncryptedJWT;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.crypto.RSAEncrypter;
private String buildJweToken(String publicKey) throws JOSEException, NoSuchAlgorithmException, InvalidKeySpecException {
JWTClaimsSet claims = new JWTClaimsSet.Builder()
// Expire 2 hours from now. The widget calls the token callback again when it expires.
.expirationTime(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000))
// Your signed-in user's own values. userId is how Pleased recognises them next time.
.claim("email", "jane@example.com")
.claim("userId", "your-user-id")
// International format. A phone the server cannot parse makes sign-in fail.
.claim("phone", "+447700900123")
.claim("name", "Jane Doe")
// All of the above claim fields are optional.
.build();
JWEHeader header = new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A128GCM);
EncryptedJWT jwt = new EncryptedJWT(header, claims);
RSAEncrypter encrypter = new RSAEncrypter((RSAPublicKey) loadPublicKey(publicKey));
jwt.encrypt(encrypter);
return jwt.serialize();
}
private PublicKey loadPublicKey(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] rsaKey = Base64.getDecoder().decode(publicKey);
X509EncodedKeySpec spec = new X509EncodedKeySpec(rsaKey);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePublic(spec);
}
Fill in your user's real details. Pleased uses userId to recognise the same person next time. Give the phone number in international format, such as +447700900123. If Pleased cannot read the phone number, it cannot sign in a new user.
Pass the token to the widget
In the chat screen, fetchTokenFromYourBackend is your own function. Make the network call off the main thread, then pass the token on. For example:
import java.net.URL
import kotlin.concurrent.thread
fun fetchTokenFromYourBackend(onToken: (String) -> Unit) {
if (!isSignedIn()) return onToken("") // a guest: no token
thread {
// Your endpoint from above. Send your app's own session with the request, so your
// server only issues a token for the user who is actually signed in.
val token = runCatching { URL("https://api.example.com/pleased-token").readText() }
.getOrDefault("")
onToken(token)
}
}
The widget waits up to 10 seconds for an answer. If the visitor signs in or out while the chat is open, call bridge.invoke("widget:re_auth") and the widget asks for the token again.
File attachments
There is nothing more to add. When the visitor taps the attach button, PleasedWebViewSetup opens the system file picker, as long as you forward onActivityResult (Step 3). The picker only gives your app the file the visitor chooses, so you do not need a storage permission.
Important: If you set your ownWebChromeClienton theWebViewafterconfigure(), it replaces the one that opens the picker. You then have to implementonShowFileChooseryourself and always answer its callback, withnullif the visitor cancels. Otherwise the attach button stops working until the page reloads.
Voice calls
When a call starts, PleasedWebViewSetup asks for the microphone and the visitor sees the standard Android permission prompt.
The call option only appears while an agent is online for it. When nobody can answer, the widget offers Open Request instead. Have someone signed in to Pleased when you test calls.
When the microphone is blocked
If the visitor refuses the microphone twice, Android stops asking, and only your app's settings screen can turn it back on. The request_open_settings handler and the canOpenAppSettings setting in Step 3 cover this. The widget shows "Microphone access is blocked for this app" with an Open settings button. When the visitor comes back from settings, the call starts by itself.
If you remove the request_open_settings handler, remove canOpenAppSettings too. Otherwise the visitor gets a button that does nothing.
Verify the installation
- Build and run your app, then open the chat screen.
- Check that the chat opens. Signed in, the greeting should show the user's name.
- Tap the attach button, pick a file and check that it is attached.
- With an agent online, start a call and allow the microphone.
- Minimise the chat and check that you are back on the previous screen.
In a debug build you can inspect the page from Chrome on your computer at chrome://inspect. The widget's console messages are the quickest way to find what is wrong.
Troubleshooting
- "Webpage not available": add
INTERNETto the manifest. - The chat never appears: check the client ID and widget ID in the URL.
- Signed-in visitors are treated as guests: check that the
tokencallback is registered and your server returns a token. Also check the phone number format. - Two chat bubbles, or a bubble over your own screens: add
platform=appto the URL. - A blank screen after the visitor minimises the chat: handle the
closeevent. - The visitor taps Allow on the microphone prompt and nothing happens: forward
onRequestPermissionsResult. - The attach button does nothing: check that your own
WebChromeClienthas not replaced the one fromPleasedWebViewSetup. - The file picker opens but nothing is attached: forward
onActivityResult. - "We couldn't get microphone access in this app" with no button: add the
request_open_settingshandler andcanOpenAppSettings. - Calls fail with
NotReadableError: addMODIFY_AUDIO_SETTINGSto the manifest. - Calls fail with
NotAllowedErrorand no prompt appears: check that your ownWebChromeClienthas not replaced the one fromPleasedWebViewSetup. - Only Open Request is offered: no agent is online for chat or calls.