Polotno
Getting started

Android

Embed Polotno Design Editor into a native Android app using WebView

How to integrate Polotno editor into an Android app?

Polotno is a React-based web editor. You can embed it in a native Android app by bundling the editor as a single-file web app and loading it in a WebView. Your users get the full web editor, and you decide in Kotlin where designs and exports go.

Architecture overview

The integration has two layers connected by a JavaScript bridge:

  • Native layer: a Kotlin app (Jetpack Compose in the demo) that hosts a WebView and handles storage and file access
  • Web layer: a React + Vite app that runs the Polotno editor

Kotlin calls the editor with evaluateJavascript(). The editor answers through an object Kotlin registers with addJavascriptInterface().

Prerequisites

  • Android Studio with JDK 17 and Android SDK 35, targeting Android 10+ (minSdk 29 in the demo)
  • Node.js 18+ and npm (for building the web editor)

Integration approach

  1. Web editor: a React + Vite app with Polotno, built with vite-plugin-singlefile into one HTML file with all JavaScript and CSS inlined. A script copies it into app/src/main/assets/editor/.

  2. Kotlin wrapper: a WebView that serves the bundle through WebViewAssetLoader and exchanges messages with the editor.

The editor exposes window.polotnoNative with load(json), save() and export(format). It posts messages back with window.PolotnoAndroid.postMessage().

See the demo repository for the complete implementation.

Core concepts

Loading the bundle

WebViewAssetLoader serves files from assets/ on https://appassets.androidplatform.net, so the editor runs on a normal https origin and file access can stay disabled:

val assetLoader = WebViewAssetLoader.Builder()
    .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
    .build()

webView.webViewClient = object : WebViewClient() {
    override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest) =
        assetLoader.shouldInterceptRequest(request.url)
}
webView.loadUrl("https://appassets.androidplatform.net/assets/editor/index.html")

In the Polotno cabinet, add https://appassets.androidplatform.net to your API key's allowed domains. Without it, the editor shows a license warning.

When the WebView sits inside Compose's AndroidView, give it MATCH_PARENT layout params. With the default WRAP_CONTENT, the page's viewport collapses to zero height and the editor renders blank.

Message passing

  • Kotlin → JavaScript: webView.evaluateJavascript("window.polotnoNative.save()", null)
  • JavaScript → Kotlin: window.PolotnoAndroid.postMessage(JSON.stringify(message)), received by a @JavascriptInterface method

@JavascriptInterface methods run on a background thread, so hand each message to the main thread before touching UI.

When the editor script loads, it posts { type: "ready" }. Kotlin answers with load(<design JSON>), so it never calls load before the page defines it.

addJavascriptInterface() exposes the bridge to any page the WebView opens, so keep the WebView on the editor page. The demo's shouldOverrideUrlLoading sends every other link to the browser.

Data serialization

The design JSON goes into the page as is, since a JSON document is a valid JavaScript object literal. Messages from the editor are JSON strings, because addJavascriptInterface() only passes primitive types. Preview images and exports travel as base64.

Image uploads

Polotno's Upload panel uses <input type="file">, and Android WebView ignores it unless the app implements WebChromeClient.onShowFileChooser. Open the system picker and answer the callback exactly once, with null when the user cancels. If you skip that call, the input stops responding until the page reloads.

override fun onShowFileChooser(
    webView: WebView,
    filePathCallback: ValueCallback<Array<Uri>>,
    params: FileChooserParams,
): Boolean {
    pendingUpload = filePathCallback
    filePicker.launch(params.createIntent())
    return true
}

// In the ActivityResult callback:
pendingUpload?.onReceiveValue(FileChooserParams.parseResult(resultCode, data))
pendingUpload = null

Exports

The toolbar's download button starts a browser download, and a WebView ignores that. Leave downloadButtonEnabled off and trigger exports from native UI instead. The editor runs store.toDataURL() or store.toPDFDataURL() and posts the base64 result to Kotlin, which writes it to Downloads through MediaStore. On Android 10+ that needs no storage permission.

The demo puts Save and Export in a native app bar above the WebView, which leaves the Polotno toolbar its full width on a phone.

FAQ

Can the editor work offline?

The editor code is bundled in the app, but Polotno loads stock photos, templates, fonts and the PDF export library from the network. The device must be online for these features.

Enterprise clients: Full offline support (bundling all assets and fonts) is available with enterprise licenses. Contact support for details.

What are the memory considerations?

Exports cross the bridge as base64 strings, so large multi-page PDFs or high pixel ratios use a lot of memory on low-end devices. Consider:

  • Lower pixel ratios for previews (the demo uses 0.5)
  • Rendering on the server side for print-size exports

If Android kills the WebView renderer to free memory, onRenderProcessGone fires. Return true there and close or recreate the editor, or the app crashes.

Where do uploaded images go?

Polotno stores uploaded images in the design JSON as data URLs. For production, upload the file to your own storage and put its URL in the design instead.

How do I customize the editor UI?

Edit the React component in web-editor/src/App.jsx. That is where you add side panels or change the default sections.

Why does the bridge work in debug builds but not in release?

R8 can remove @JavascriptInterface methods it sees no Kotlin callers for. Add a keep rule for the bridge class:

-keep class com.polotno.android.EditorBridge {
    @android.webkit.JavascriptInterface <methods>;
}

How do I debug the editor?

Call WebView.setWebContentsDebuggingEnabled(true) in debug builds, then open chrome://inspect in desktop Chrome. Page console messages also appear in Logcat under the chromium tag.

How do I handle errors?

Wrap Polotno API calls in try-catch on the JavaScript side and post an error message to Kotlin, so the native UI can clear its loading state. In Kotlin, validate each message before you use it.

Demo repository

See a complete working example with source code: https://github.com/polotno-project/polotno-android

The repository includes a Kotlin + Jetpack Compose app and the React + Vite web editor that runs inside it.

On this page