Today WebView Bridge
Native Reference

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. via WebViewCompat.addDocumentStartJavaScript), so the channel exists on first paint.
  • Platform tag (optional). The injected channel object already identifies Android, so platform resolves without help. For symmetry with Apple you MAY also inject window.__todayWebView = { platform: 'android' } at document start. See Environment detection.
  • Origin scope. Restrict addWebMessageListener to the exact origins that may use the bridge. Do not allow *.
  • @JavascriptInterface is 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-null for track and unknown types; reject only from refreshToken on terminal failure.
  • Coalescing. tokenStore.refreshCoalesced() MUST collapse concurrent refreshes into one real round-trip, same as iOS.
  • establishSession is the recommended path. Mint the embed_bearer cookie (Path=/api/; Secure; HttpOnly) into the WebView's own store via CookieManager.getInstance().setCookie(...) and reply { ok: true }; reply { ok: false } on failure — never reject, never return the bearer. refresh: true forces a refreshCoalesced() first; same trust gate as headers. 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 to headers and the web's /api/auth/embed-session exchange. See establishSession.

On this page