Android
Implementing the contract with a promise-returning JS shim.
Android needs one extra step that iOS does not. The classic
@JavascriptInterface is synchronous and string-only — it cannot return a
Promise. So a conforming Android host injects a thin JS shim that presents the
same postMessage(message): Promise surface the web SDK expects on
window.todayWebViewBridge, and backs it with an async native call.
Two mechanisms work. Prefer the first.
Option A — WebViewCompat.addWebMessageListener (preferred)
AndroidX's WebMessageListener gives a clean bidirectional channel that maps
naturally to request/reply with a request-id correlation.
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
"todayWebViewBridgeNative", // raw native port; the shim wraps it
setOf("https://today.ai"), // allowed origins — scope tightly
) { _, message, _, replyProxy ->
val request = JSONObject(message.data ?: "{}")
val id = request.optString("id")
handle(request) { resultJson ->
replyProxy.postMessage(JSONObject().put("id", id).put("result", resultJson).toString())
}
}
}The injected shim turns that raw port into the promise API:
// Injected by the host before page scripts run.
;(() => {
const pending = new Map()
window.todayWebViewBridgeNative.onmessage = (e) => {
const { id, result, error } = JSON.parse(e.data)
const p = pending.get(id)
if (!p) return
pending.delete(id)
error == null ? p.resolve(result) : p.reject(new Error(error))
}
window.todayWebViewBridge = {
postMessage(message) {
const id = crypto.randomUUID()
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
window.todayWebViewBridgeNative.postMessage(JSON.stringify({ id, ...message }))
})
},
}
})()Option B — @JavascriptInterface + callback
Where WEB_MESSAGE_LISTENER is unavailable, pair a synchronous
@JavascriptInterface with a JS callback the native side invokes via
evaluateJavascript.
class TodayWebViewBridge(private val webView: WebView) {
@JavascriptInterface
fun postMessage(id: String, json: String) {
val request = JSONObject(json)
handle(request) { resultJson, error ->
val payload = JSONObject().put("id", id)
.apply { if (error != null) put("error", error) else put("result", resultJson) }
webView.post {
webView.evaluateJavascript("window.__todayWebViewBridgeResolve($payload)", null)
}
}
}
}
// webView.addJavascriptInterface(TodayWebViewBridge(webView), "todayWebViewBridgeNative")The injected shim is the same shape as Option A: it generates an id, stores the
{ resolve, reject }, calls the native interface, and settles when
__todayWebViewBridgeResolve fires.
Dispatch — shared by both options
fun handle(request: JSONObject, reply: (resultJson: Any?, error: String?) -> Unit) {
when (request.optString("type")) {
"track" -> {
analytics.track(request.optString("ename"), enrich(scalarsOnly(request.optJSONObject("parameters"))))
reply(null, null) // fire-and-forget → resolve undefined
}
"headers" -> reply(currentInjectedHeaders(), null) // JSON object, maybe {}
"refreshToken" -> scope.launch {
try {
val token = tokenStore.refreshCoalesced()
reply(JSONObject().put("authorization", token.authorization), null) // ready-to-use value only, never the raw token
} catch (e: Exception) {
reply(null, e.message ?: "refresh failed") // reject with a string
}
}
"establishSession" -> scope.launch {
try {
val token = if (request.optBoolean("refresh")) tokenStore.refreshCoalesced()
else tokenStore.current()
// Mint the cookie into the WebView's own store — never return the bearer.
CookieManager.getInstance().setCookie(
trustedOrigin,
"embed_bearer=${token.rawValue}; Path=/api/; Secure; HttpOnly",
)
reply(JSONObject().put("ok", true), null)
} catch (e: Exception) {
reply(JSONObject().put("ok", false), null) // resolve { ok: false }, never reject
}
}
else -> reply(null, null) // unknown type → resolve undefined
}
}Contract notes specific to Android
- Channel name. The web SDK looks for
window.todayWebViewBridge. Inject the shim that defines it before page scripts run (e.g. viaWebViewCompat.addDocumentStartJavaScript), so the channel exists on first paint. - Platform tag (optional). The injected channel object already identifies
Android, so
platformresolves without help. For symmetry with Apple you MAY also injectwindow.__todayWebView = { platform: 'android' }at document start. See Environment detection. - Origin scope. Restrict
addWebMessageListenerto the exact origins that may use the bridge. Do not allow*. @JavascriptInterfaceis string-only. Pass JSON strings across the boundary and parse on each side; the shim is what restores the typed, promise-based surface.- Resolve vs reject.
reply(value, null)resolves;reply(null, "message")rejects with that string. Use resolve-nullfortrackand unknown types; reject only fromrefreshTokenon terminal failure. - Coalescing.
tokenStore.refreshCoalesced()MUST collapse concurrent refreshes into one real round-trip, same as iOS. establishSessionis the recommended path. Mint theembed_bearercookie (Path=/api/; Secure; HttpOnly) into the WebView's own store viaCookieManager.getInstance().setCookie(...)and reply{ ok: true }; reply{ ok: false }on failure — never reject, never return the bearer.refresh: trueforces arefreshCoalesced()first; same trust gate asheaders. The contract is defined and the web side already prefers it; the native implementation is a per-platform follow-up — until it lands, the host falls through toheadersand the web's/api/auth/embed-sessionexchange. SeeestablishSession.