
Ready-to-ship automation for library releases: converts README into a homepage, publishes API docs and interactive browser demo, generates downloadable sample apps, and wires full CI/CD.
A comprehensive WebView library for Compose Multiplatform that enables you to display web content and HTML across Android, iOS, Desktop (JVM), and Web (WASM) platforms with a unified API.
Key Features:
onScriptResult)WebViewController.evaluateJavaScript)| Capability | Android | iOS | Desktop (JVM) | Web (WASM) |
|---|---|---|---|---|
Load remote url
|
✅ | ✅ | ✅ | ✅ (iframe; subject to CSP/X-Frame-Options) |
Load htmlContent
|
✅ | ✅ | ✅ | ✅ |
Custom request headers (headers) |
✅ | ❌ | ❌ | ❌ |
JS → Compose (onScriptResult) with htmlContent
|
✅ | ✅ | ✅ | ✅ |
JS → Compose (onScriptResult) with remote url
|
✅ (bridge injected) | ✅ (bridge injected) | ✅ (bridge injected) | |
Compose → JS (WebViewController.evaluateJavaScript) |
✅ | ✅ | ✅ (executes; no return values yet) | ✅ (same-origin / htmlContent only)
|
evaluateJavaScript return values
|
✅ | ✅ | ❌ (returns Unsupported)
|
✅ (same-origin / htmlContent only)
|
Notes:
htmlContent or same-origin pages you control.evaluateJavaScript currently does not surface return values (it returns WebViewJsResult.Unsupported(...)).Add the dependency to your Compose Multiplatform project:
Option A — Version catalog (recommended)
gradle/libs.versions.toml:[versions]
cmpWebview = "0.0.1"
[libraries]
cmp-webview = { module = "io.github.aryapreetam:cmp-webview", version.ref = "cmpWebview" }build.gradle.kts:kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.cmp.webview)
}
}
}
}Option B — Direct dependency
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.github.aryapreetam:cmp-webview:0.0.1")
}
}
}
}@Composable
fun MyScreen() {
WebView(
url = "https://example.com",
modifier = Modifier.fillMaxSize(),
onLoadStarted = { println("Loading started") },
onLoadFinished = { println("Loading finished") },
onLoadError = { error -> println("Error: $error") }
)
}@Composable
fun HtmlScreen() {
val html = """
<html>
<body>
<h1>Hello from HTML!</h1>
<p>This is rendered locally.</p>
</body>
</html>
""".trimIndent()
WebView(
htmlContent = html,
modifier = Modifier.fillMaxSize()
)
}JavaScript code in your web content can communicate with your Compose code:
In your HTML/JavaScript:
// Listen for bridge ready event
window.addEventListener('ComposeWebViewBridgeReady', function () {
// Send message to Compose
ComposeWebViewBridge.postMessage('Hello from JavaScript!');
});
// Example: Send button click data
document.getElementById('myButton').addEventListener('click', function () {
ComposeWebViewBridge.postMessage(JSON.stringify({
action: 'buttonClick',
data: 'some value'
}));
});In your Compose code:
@Composable
fun BridgeExample() {
WebView(
url = "https://example.com",
onScriptResult = { message ->
println("Received from JavaScript: $message")
// Parse JSON if needed
val data = Json.decodeFromString<MyData>(message)
// Handle the message
}
)
}If you need to call JavaScript from Compose, pass a WebViewController.
@Composable
fun ComposeToJsExample() {
val controller = rememberWebViewController()
val scope = rememberCoroutineScope()
Column {
Button(onClick = {
scope.launch {
controller.evaluateJavaScript(
"document.body.style.background = 'tomato';" +
"window.ComposeWebViewBridge?.postMessage('ack');"
)
}
}) {
Text("Run JS")
}
WebView(
htmlContent = "<html><body>...</body></html>",
controller = controller,
onScriptResult = { msg -> println("JS→Compose: $msg") },
)
}
}Notes:
htmlContent or same-origin content.Note: Custom headers are currently supported on Android only. Other targets will ignore headers or do not support attaching headers to a top-level navigation.
WebView(
url = "https://api.example.com/data",
headers = mapOf(
"Authorization" to "Bearer YOUR_TOKEN",
"User-Agent" to "MyApp/1.0"
),
onLoadFinished = { println("Authenticated content loaded") }
)Use baseUrl to resolve relative links in your HTML:
val html = """
<html>
<body>
<img src="https://raw.githubusercontent.com/aryapreetam/cmp-webview/main/images/logo.png" />
<a href="https://github.com/aryapreetam/cmp-webview/blob/main/page2.html">Next Page</a>
</body>
</html>
""".trimIndent()
WebView(
htmlContent = html,
baseUrl = "https://example.com/", // Resolves to example.com/images/logo.png
)var errorMessage by remember { mutableStateOf<String?>(null) }
if (errorMessage != null) {
Text("Error: $errorMessage")
Button(onClick = { errorMessage = null }) {
Text("Retry")
}
} else {
WebView(
url = "https://example.com",
onLoadError = { error ->
errorMessage = error
}
)
}The library automatically rejects dangerous URL schemes:
javascript: URLs (XSS risk)vbscript: URLs (XSS risk)file: URLs (local file access)https: URLs (recommended)http: URLs (use with caution)Best Practices:
// Example: URL validation
fun loadUserUrl(userInput: String) {
val url = userInput.trim()
// Validate scheme
if (!url.startsWith("https://")) {
showError("Only HTTPS URLs are allowed")
return
}
// Validate against allowlist
val allowedDomains = listOf("example.com", "trusted-site.com")
if (!allowedDomains.any { url.contains(it) }) {
showError("Domain not allowed")
return
}
// Safe to load
WebView(url = url)
}Always validate and sanitize messages from JavaScript:
WebView(
url = "https://example.com",
onScriptResult = { message ->
try {
// Validate message format
require(message.length < 10_000) { "Message too large" }
// Parse and validate JSON
val data = Json.decodeFromString<MyData>(message)
require(data.userId.isNotBlank()) { "Invalid user ID" }
// Process validated data
handleValidatedData(data)
} catch (e: Exception) {
Log.w("WebView", "Invalid bridge message: ${e.message}")
}
}
)For HTML content, include CSP headers to restrict resource loading:
val secureHtml = """
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' https://trusted-cdn.com; script-src 'self'">
</head>
<body>...</body>
</html>
""".trimIndent()
WebView(htmlContent = secureHtml)WebView is an interop component on several targets:
AndroidView ✅ testable via Android instrumented Compose UI testsUIKitView runComposeUiTest currently fails for interop views (LocalInteropContainer not provided)WebElementView runComposeUiTest currently fails for interop views (LocalInteropContainer not provided)SwingPanel runComposeUiTest does not provide LocalInteropContainer for SwingPanel
Practical strategy:
sample/composeApp/src/jvmTest/.../WebViewBridgeIntegrationJvmTest.kt).Use the testTag parameter to identify WebViews in tests:
Note: The snippet below assumes an Android-style Compose UI test rule. For Desktop interop views, use a ComposePanel/ComposeWindow-based harness; for iOS/WASM, avoid
runComposeUiTestfor interop.
// In your composable
@Composable
fun MyScreen() {
WebView(
url = "https://example.com",
testTag = "main-webview"
)
}
// In your test
@Test
fun testWebViewLoads() {
composeTestRule.setContent {
MyScreen()
}
// Find WebView by test tag
composeTestRule
.onNodeWithTag("main-webview")
.assertExists()
}The WebView exposes loading state through semantics:
@Test
fun testLoadingState() {
composeTestRule.setContent {
WebView(
url = "https://example.com",
testTag = "test-webview"
)
}
// Verify loading state is announced
composeTestRule
.onNodeWithTag("test-webview")
.assertExists()
.assert(hasStateDescription("Loading"))
// Wait for loaded state
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onNodeWithTag("test-webview")
.fetchSemanticsNode()
.config[SemanticsProperties.StateDescription] == "Loaded"
}
}@Test
fun testBridgeMessage() {
var receivedMessage: String? = null
composeTestRule.setContent {
WebView(
htmlContent = """
<html><body><script>
window.addEventListener('ComposeWebViewBridgeReady', function() {
ComposeWebViewBridge.postMessage('test-message');
});
</script></body></html>
""".trimIndent(),
onScriptResult = { message ->
receivedMessage = message
}
)
}
// Wait for bridge message
composeTestRule.waitUntil(timeoutMillis = 3000) {
receivedMessage == "test-message"
}
assertEquals("test-message", receivedMessage)
}The WebView library provides built-in accessibility support for screen readers like TalkBack (Android) and VoiceOver ( iOS).
Every WebView automatically provides:
WebView(
url = "https://example.com",
onLoadStarted = {
// Screen reader announces: "Web content display, Loading"
},
onLoadFinished = {
// Screen reader announces: "Web content display, Loaded"
},
onLoadError = { error ->
// Screen reader announces: "Web content display, Error"
}
)Provide meaningful test tags for navigation:
WebView(url = "...", testTag = "article-webview")Use descriptive error messages for screen readers:
WebView(
url = "...",
onLoadError = { error ->
announceForAccessibility("Failed to load page: $error")
}
)window.javaBridge → window.kmpJsBridge)Problem: JavaScript bridge messages aren't reaching Compose code.
Solutions:
Wait for bridge ready:
window.addEventListener('ComposeWebViewBridgeReady', function() {
// Now safe to send messages
ComposeWebViewBridge.postMessage('hello');
});Check platform compatibility:
javaBridge
Verify callback is set:
WebView(
url = "...",
onScriptResult = { message -> // Must provide this callback
println("Received: $message")
}
)Problem: WebView fails to load URL or shows error.
Solutions:
Check internet connectivity
Verify URL is valid and accessible:
WebView(
url = "https://example.com", // Must be valid HTTPS/HTTP
onLoadError = { error ->
Log.e("WebView", "Load failed: $error")
}
)Check for blocked schemes:
javascript:, vbscript:, file: are blocked for securityVerify SSL certificate (HTTPS only):
Problem: WebView appears blank or doesn't show content.
Solutions:
Check modifier size:
WebView(
url = "...",
modifier = Modifier.fillMaxSize() // Or specific size
)Verify content loaded:
WebView(
url = "...",
onLoadFinished = {
println("Content loaded successfully")
}
)Check HTML validity (for HTML content):
val html = "<html><body>Content</body></html>" // Must be valid HTML
WebView(htmlContent = html)Problem: After closing a Desktop app, the IDE or Gradle process may stay unresponsive.
Solutions:
Problem: App memory grows over time with WebViews.
Solutions:
Problem: Desktop WebView can only be created once per application session.
Solutions:
var currentUrl by remember { mutableStateOf("https://example.com") }
// Reuse same WebView, just change URL
WebView(url = currentUrl)
// Change URL instead of recreating
Button(onClick = { currentUrl = "https://other-site.com" }) {
Text("Load Other Site")
}See specs/001-add-url-loading/desktop-webview-limitation.md for detailed explanation.
Download and try the sample app on your platform without building from source:
| Platform | Download Link |
|---|---|
| 🍏 macOS (Intel) | |
| 🍎 macOS (Apple Silicon) | |
| 🪟 Windows | |
| 🐧 Linux | |
| 🤖 Android | |
| 🌐 Web (Wasm) | |
| iOS Simulator |
Option 1: Drag and Drop
Option 2: ADB Install
adb install sample-app-android-unsigned.apksample-app-wasm.zip
index.html in a web browserNote: You can also try the live demo without downloading: Try Live Demo
sample-app-ios-simulator.zip from the latest release
sample-app-ios-simulator.app
.app onto the Simulator window OR run:
xcrun simctl install booted /path/to/sample-app-ios-simulator.appDownload the DMG for your Mac architecture (Intel or Apple Silicon)
Open (mount) the DMG and drag the app to your Applications folder (or Desktop)
When you try to open the app for the first time, macOS Gatekeeper will block it since it is an open-source build signed ad-hoc (not using a paid Apple Developer account). Follow these 6 sequential steps to allow running the app:
1. Block Alert Dialog
|
2. Open System Settings
|
3. Privacy & Security Section
|
4. Click "Open Anyway"
|
5. Authenticate Security Dialog
|
6. Click "Open" to Launch
|
To allow running the app:
This is a normal security step for all open-source and CI-generated Mac executables. Apps are signed ad-hoc for internal/dev use, not with a public Apple developer ID.
Tip: Unsure about your Mac's type? Click the Apple logo → "About This Mac". If it says Intel, download x64; if it says M1, M2, or M3, download arm64.
.deb filesudo dpkg -i sample-app-linux.debsample in the terminal!This template automatically generates and deploys three key resources for your library:
All three are automatically deployed to GitHub Pages:
https://<username>.github.io/<library-name>/
https://<username>.github.io/<library-name>/api/
https://<username>.github.io/<library-name>/demo/
You can also run Gradle tasks in the terminal:
./gradlew run to run application./gradlew package to store native distribution into build/compose/binaries
Desktop JVM (recommended): ./gradlew :sample:composeApp:jvmRun -DmainClass=MainKt
Note: On Desktop, :sample:composeApp:run may sometimes hang depending on the Gradle JVM configuration. If that happens, prefer jvmRun and ensure Gradle uses JetBrains Runtime (JBR).
Android: Open project in Android Studio and run the sample app
iOS: Open sample/iosApp/iosApp.xcodeproj in Xcode and run the sample app
Wasm: ./gradlew :sample:composeApp:wasmJsBrowserRun
The API documentation appearance has improved with Dokka 2.1.0-Beta. Below is a quick visual comparison.
| Dokka (2.0.0) | Dokka (2.1.0-Beta) |
|---|---|
![]() |
![]() |
![]() |
![]() |
This repo ships with an opinionated CI/CD that builds, tests, packages, releases, and deploys docs & demo automatically on tags.
flowchart TD
A["Lint"] --> B["Build & Test - Library + Sample (all targets)"]
B --> C["Android UI tests (emulator) & Maestro E2E tests(iOS & Android)"]
C --> D["Build artifacts - all targets APK • DMG • iOS Simulator zip • wasm bundle"]
D --> E["Create GitHub Release and upload artifacts"]
E --> F["Publish to Maven Central"]
E --> G["Deploy GitHub Pages README site (Docsify) + wasm demo + API docs"]Key points:
v*) and manual runs../gradlew :lib:publishToMavenLocal
~/.m2/repository/cmp-webview/
Follow https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-publish-libraries.html & complete all necessary steps.
The above article focuses on publishing to MavenCentral. But if you want to verify publishing from your local machine, you can follow below steps:
~/.gradle/gradle.properties(not to be confused with gradle.properties in project root):
signing.keyId=XXXXXXXX
signing.password=[key password]
signing.secretKeyRingFile=../XXXXXXXX.gpg
mavenCentralUsername=[generated username]
mavenCentralPassword=[generated password]
For GPG_KEY_CONTENTS secret(signing.secretKeyRingFile above), can get plain text version of gpg key using below
commands.
# This will print the private GPG key in plain text.
gpg --export-secret-keys --armor <key id>
# this will copy it for pasting in github actions secrets.
gpg --export-secret-keys --armor <key id> | pbcopy
./gradlew :lib:publishAndReleaseToMavenCentral --no-configuration-cache
MIT License © 2025 aryapreetam and contributors. See LICENSE for details.
This template was built with inspiration and learnings from the excellent work of the Kotlin multiplatform community:
Project Setup: Initial project structure was created using Kotlin Multiplatform Web Wizard by terrakok
Inspirations:
Special thanks to:
This section lists planned improvements and features for the template. Contributions are welcome!
[ ] Add Detekt - Static code analysis for Kotlin
[ ] Add ktlint - Kotlin code formatter
[ ] Add Kover - Code coverage reporting
[ ] Add Dependabot - Automated dependency updates
[ ] Screenshot Testing - Visual regression testing
[ ] Performance Benchmarking - Track performance metrics
[ ] Multi-Module Support Guide
[ ] Compose Resources Guide
[ ] Video Tutorial
[ ] Blog Post / Article
[ ] Automated Changelog Generation
[ ] Version Bump Automation
[ ] Automated Screenshots
[ ] Linux Desktop Packaging
[ ] Additional Apple Platforms
[ ] Desktop Linux Native Build
[ ] Custom Domain Support
[ ] Improved Docsify Theme
[ ] Search Functionality
[ ] Vulnerability Scanning
[ ] SBOM Generation
[ ] Signed Releases
[ ] JitPack Support
[ ] NPM Package for Wasm
[ ] CocoaPods Support
If you have suggestions for improvements or want to contribute:
Priority: Items marked with 🔥 are high priority and would have the most impact.
See CONTRIBUTING.md for detailed contribution guidelines.
A comprehensive WebView library for Compose Multiplatform that enables you to display web content and HTML across Android, iOS, Desktop (JVM), and Web (WASM) platforms with a unified API.
Key Features:
onScriptResult)WebViewController.evaluateJavaScript)| Capability | Android | iOS | Desktop (JVM) | Web (WASM) |
|---|---|---|---|---|
Load remote url
|
✅ | ✅ | ✅ | ✅ (iframe; subject to CSP/X-Frame-Options) |
Load htmlContent
|
✅ | ✅ | ✅ | ✅ |
Custom request headers (headers) |
✅ | ❌ | ❌ | ❌ |
JS → Compose (onScriptResult) with htmlContent
|
✅ | ✅ | ✅ | ✅ |
JS → Compose (onScriptResult) with remote url
|
✅ (bridge injected) | ✅ (bridge injected) | ✅ (bridge injected) | |
Compose → JS (WebViewController.evaluateJavaScript) |
✅ | ✅ | ✅ (executes; no return values yet) | ✅ (same-origin / htmlContent only)
|
evaluateJavaScript return values
|
✅ | ✅ | ❌ (returns Unsupported)
|
✅ (same-origin / htmlContent only)
|
Notes:
htmlContent or same-origin pages you control.evaluateJavaScript currently does not surface return values (it returns WebViewJsResult.Unsupported(...)).Add the dependency to your Compose Multiplatform project:
Option A — Version catalog (recommended)
gradle/libs.versions.toml:[versions]
cmpWebview = "0.0.1"
[libraries]
cmp-webview = { module = "io.github.aryapreetam:cmp-webview", version.ref = "cmpWebview" }build.gradle.kts:kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.cmp.webview)
}
}
}
}Option B — Direct dependency
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.github.aryapreetam:cmp-webview:0.0.1")
}
}
}
}@Composable
fun MyScreen() {
WebView(
url = "https://example.com",
modifier = Modifier.fillMaxSize(),
onLoadStarted = { println("Loading started") },
onLoadFinished = { println("Loading finished") },
onLoadError = { error -> println("Error: $error") }
)
}@Composable
fun HtmlScreen() {
val html = """
<html>
<body>
<h1>Hello from HTML!</h1>
<p>This is rendered locally.</p>
</body>
</html>
""".trimIndent()
WebView(
htmlContent = html,
modifier = Modifier.fillMaxSize()
)
}JavaScript code in your web content can communicate with your Compose code:
In your HTML/JavaScript:
// Listen for bridge ready event
window.addEventListener('ComposeWebViewBridgeReady', function () {
// Send message to Compose
ComposeWebViewBridge.postMessage('Hello from JavaScript!');
});
// Example: Send button click data
document.getElementById('myButton').addEventListener('click', function () {
ComposeWebViewBridge.postMessage(JSON.stringify({
action: 'buttonClick',
data: 'some value'
}));
});In your Compose code:
@Composable
fun BridgeExample() {
WebView(
url = "https://example.com",
onScriptResult = { message ->
println("Received from JavaScript: $message")
// Parse JSON if needed
val data = Json.decodeFromString<MyData>(message)
// Handle the message
}
)
}If you need to call JavaScript from Compose, pass a WebViewController.
@Composable
fun ComposeToJsExample() {
val controller = rememberWebViewController()
val scope = rememberCoroutineScope()
Column {
Button(onClick = {
scope.launch {
controller.evaluateJavaScript(
"document.body.style.background = 'tomato';" +
"window.ComposeWebViewBridge?.postMessage('ack');"
)
}
}) {
Text("Run JS")
}
WebView(
htmlContent = "<html><body>...</body></html>",
controller = controller,
onScriptResult = { msg -> println("JS→Compose: $msg") },
)
}
}Notes:
htmlContent or same-origin content.Note: Custom headers are currently supported on Android only. Other targets will ignore headers or do not support attaching headers to a top-level navigation.
WebView(
url = "https://api.example.com/data",
headers = mapOf(
"Authorization" to "Bearer YOUR_TOKEN",
"User-Agent" to "MyApp/1.0"
),
onLoadFinished = { println("Authenticated content loaded") }
)Use baseUrl to resolve relative links in your HTML:
val html = """
<html>
<body>
<img src="https://raw.githubusercontent.com/aryapreetam/cmp-webview/main/images/logo.png" />
<a href="https://github.com/aryapreetam/cmp-webview/blob/main/page2.html">Next Page</a>
</body>
</html>
""".trimIndent()
WebView(
htmlContent = html,
baseUrl = "https://example.com/", // Resolves to example.com/images/logo.png
)var errorMessage by remember { mutableStateOf<String?>(null) }
if (errorMessage != null) {
Text("Error: $errorMessage")
Button(onClick = { errorMessage = null }) {
Text("Retry")
}
} else {
WebView(
url = "https://example.com",
onLoadError = { error ->
errorMessage = error
}
)
}The library automatically rejects dangerous URL schemes:
javascript: URLs (XSS risk)vbscript: URLs (XSS risk)file: URLs (local file access)https: URLs (recommended)http: URLs (use with caution)Best Practices:
// Example: URL validation
fun loadUserUrl(userInput: String) {
val url = userInput.trim()
// Validate scheme
if (!url.startsWith("https://")) {
showError("Only HTTPS URLs are allowed")
return
}
// Validate against allowlist
val allowedDomains = listOf("example.com", "trusted-site.com")
if (!allowedDomains.any { url.contains(it) }) {
showError("Domain not allowed")
return
}
// Safe to load
WebView(url = url)
}Always validate and sanitize messages from JavaScript:
WebView(
url = "https://example.com",
onScriptResult = { message ->
try {
// Validate message format
require(message.length < 10_000) { "Message too large" }
// Parse and validate JSON
val data = Json.decodeFromString<MyData>(message)
require(data.userId.isNotBlank()) { "Invalid user ID" }
// Process validated data
handleValidatedData(data)
} catch (e: Exception) {
Log.w("WebView", "Invalid bridge message: ${e.message}")
}
}
)For HTML content, include CSP headers to restrict resource loading:
val secureHtml = """
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' https://trusted-cdn.com; script-src 'self'">
</head>
<body>...</body>
</html>
""".trimIndent()
WebView(htmlContent = secureHtml)WebView is an interop component on several targets:
AndroidView ✅ testable via Android instrumented Compose UI testsUIKitView runComposeUiTest currently fails for interop views (LocalInteropContainer not provided)WebElementView runComposeUiTest currently fails for interop views (LocalInteropContainer not provided)SwingPanel runComposeUiTest does not provide LocalInteropContainer for SwingPanel
Practical strategy:
sample/composeApp/src/jvmTest/.../WebViewBridgeIntegrationJvmTest.kt).Use the testTag parameter to identify WebViews in tests:
Note: The snippet below assumes an Android-style Compose UI test rule. For Desktop interop views, use a ComposePanel/ComposeWindow-based harness; for iOS/WASM, avoid
runComposeUiTestfor interop.
// In your composable
@Composable
fun MyScreen() {
WebView(
url = "https://example.com",
testTag = "main-webview"
)
}
// In your test
@Test
fun testWebViewLoads() {
composeTestRule.setContent {
MyScreen()
}
// Find WebView by test tag
composeTestRule
.onNodeWithTag("main-webview")
.assertExists()
}The WebView exposes loading state through semantics:
@Test
fun testLoadingState() {
composeTestRule.setContent {
WebView(
url = "https://example.com",
testTag = "test-webview"
)
}
// Verify loading state is announced
composeTestRule
.onNodeWithTag("test-webview")
.assertExists()
.assert(hasStateDescription("Loading"))
// Wait for loaded state
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onNodeWithTag("test-webview")
.fetchSemanticsNode()
.config[SemanticsProperties.StateDescription] == "Loaded"
}
}@Test
fun testBridgeMessage() {
var receivedMessage: String? = null
composeTestRule.setContent {
WebView(
htmlContent = """
<html><body><script>
window.addEventListener('ComposeWebViewBridgeReady', function() {
ComposeWebViewBridge.postMessage('test-message');
});
</script></body></html>
""".trimIndent(),
onScriptResult = { message ->
receivedMessage = message
}
)
}
// Wait for bridge message
composeTestRule.waitUntil(timeoutMillis = 3000) {
receivedMessage == "test-message"
}
assertEquals("test-message", receivedMessage)
}The WebView library provides built-in accessibility support for screen readers like TalkBack (Android) and VoiceOver ( iOS).
Every WebView automatically provides:
WebView(
url = "https://example.com",
onLoadStarted = {
// Screen reader announces: "Web content display, Loading"
},
onLoadFinished = {
// Screen reader announces: "Web content display, Loaded"
},
onLoadError = { error ->
// Screen reader announces: "Web content display, Error"
}
)Provide meaningful test tags for navigation:
WebView(url = "...", testTag = "article-webview")Use descriptive error messages for screen readers:
WebView(
url = "...",
onLoadError = { error ->
announceForAccessibility("Failed to load page: $error")
}
)window.javaBridge → window.kmpJsBridge)Problem: JavaScript bridge messages aren't reaching Compose code.
Solutions:
Wait for bridge ready:
window.addEventListener('ComposeWebViewBridgeReady', function() {
// Now safe to send messages
ComposeWebViewBridge.postMessage('hello');
});Check platform compatibility:
javaBridge
Verify callback is set:
WebView(
url = "...",
onScriptResult = { message -> // Must provide this callback
println("Received: $message")
}
)Problem: WebView fails to load URL or shows error.
Solutions:
Check internet connectivity
Verify URL is valid and accessible:
WebView(
url = "https://example.com", // Must be valid HTTPS/HTTP
onLoadError = { error ->
Log.e("WebView", "Load failed: $error")
}
)Check for blocked schemes:
javascript:, vbscript:, file: are blocked for securityVerify SSL certificate (HTTPS only):
Problem: WebView appears blank or doesn't show content.
Solutions:
Check modifier size:
WebView(
url = "...",
modifier = Modifier.fillMaxSize() // Or specific size
)Verify content loaded:
WebView(
url = "...",
onLoadFinished = {
println("Content loaded successfully")
}
)Check HTML validity (for HTML content):
val html = "<html><body>Content</body></html>" // Must be valid HTML
WebView(htmlContent = html)Problem: After closing a Desktop app, the IDE or Gradle process may stay unresponsive.
Solutions:
Problem: App memory grows over time with WebViews.
Solutions:
Problem: Desktop WebView can only be created once per application session.
Solutions:
var currentUrl by remember { mutableStateOf("https://example.com") }
// Reuse same WebView, just change URL
WebView(url = currentUrl)
// Change URL instead of recreating
Button(onClick = { currentUrl = "https://other-site.com" }) {
Text("Load Other Site")
}See specs/001-add-url-loading/desktop-webview-limitation.md for detailed explanation.
Download and try the sample app on your platform without building from source:
| Platform | Download Link |
|---|---|
| 🍏 macOS (Intel) | |
| 🍎 macOS (Apple Silicon) | |
| 🪟 Windows | |
| 🐧 Linux | |
| 🤖 Android | |
| 🌐 Web (Wasm) | |
| iOS Simulator |
Option 1: Drag and Drop
Option 2: ADB Install
adb install sample-app-android-unsigned.apksample-app-wasm.zip
index.html in a web browserNote: You can also try the live demo without downloading: Try Live Demo
sample-app-ios-simulator.zip from the latest release
sample-app-ios-simulator.app
.app onto the Simulator window OR run:
xcrun simctl install booted /path/to/sample-app-ios-simulator.appDownload the DMG for your Mac architecture (Intel or Apple Silicon)
Open (mount) the DMG and drag the app to your Applications folder (or Desktop)
When you try to open the app for the first time, macOS Gatekeeper will block it since it is an open-source build signed ad-hoc (not using a paid Apple Developer account). Follow these 6 sequential steps to allow running the app:
1. Block Alert Dialog
|
2. Open System Settings
|
3. Privacy & Security Section
|
4. Click "Open Anyway"
|
5. Authenticate Security Dialog
|
6. Click "Open" to Launch
|
To allow running the app:
This is a normal security step for all open-source and CI-generated Mac executables. Apps are signed ad-hoc for internal/dev use, not with a public Apple developer ID.
Tip: Unsure about your Mac's type? Click the Apple logo → "About This Mac". If it says Intel, download x64; if it says M1, M2, or M3, download arm64.
.deb filesudo dpkg -i sample-app-linux.debsample in the terminal!This template automatically generates and deploys three key resources for your library:
All three are automatically deployed to GitHub Pages:
https://<username>.github.io/<library-name>/
https://<username>.github.io/<library-name>/api/
https://<username>.github.io/<library-name>/demo/
You can also run Gradle tasks in the terminal:
./gradlew run to run application./gradlew package to store native distribution into build/compose/binaries
Desktop JVM (recommended): ./gradlew :sample:composeApp:jvmRun -DmainClass=MainKt
Note: On Desktop, :sample:composeApp:run may sometimes hang depending on the Gradle JVM configuration. If that happens, prefer jvmRun and ensure Gradle uses JetBrains Runtime (JBR).
Android: Open project in Android Studio and run the sample app
iOS: Open sample/iosApp/iosApp.xcodeproj in Xcode and run the sample app
Wasm: ./gradlew :sample:composeApp:wasmJsBrowserRun
The API documentation appearance has improved with Dokka 2.1.0-Beta. Below is a quick visual comparison.
| Dokka (2.0.0) | Dokka (2.1.0-Beta) |
|---|---|
![]() |
![]() |
![]() |
![]() |
This repo ships with an opinionated CI/CD that builds, tests, packages, releases, and deploys docs & demo automatically on tags.
flowchart TD
A["Lint"] --> B["Build & Test - Library + Sample (all targets)"]
B --> C["Android UI tests (emulator) & Maestro E2E tests(iOS & Android)"]
C --> D["Build artifacts - all targets APK • DMG • iOS Simulator zip • wasm bundle"]
D --> E["Create GitHub Release and upload artifacts"]
E --> F["Publish to Maven Central"]
E --> G["Deploy GitHub Pages README site (Docsify) + wasm demo + API docs"]Key points:
v*) and manual runs../gradlew :lib:publishToMavenLocal
~/.m2/repository/cmp-webview/
Follow https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-publish-libraries.html & complete all necessary steps.
The above article focuses on publishing to MavenCentral. But if you want to verify publishing from your local machine, you can follow below steps:
~/.gradle/gradle.properties(not to be confused with gradle.properties in project root):
signing.keyId=XXXXXXXX
signing.password=[key password]
signing.secretKeyRingFile=../XXXXXXXX.gpg
mavenCentralUsername=[generated username]
mavenCentralPassword=[generated password]
For GPG_KEY_CONTENTS secret(signing.secretKeyRingFile above), can get plain text version of gpg key using below
commands.
# This will print the private GPG key in plain text.
gpg --export-secret-keys --armor <key id>
# this will copy it for pasting in github actions secrets.
gpg --export-secret-keys --armor <key id> | pbcopy
./gradlew :lib:publishAndReleaseToMavenCentral --no-configuration-cache
MIT License © 2025 aryapreetam and contributors. See LICENSE for details.
This template was built with inspiration and learnings from the excellent work of the Kotlin multiplatform community:
Project Setup: Initial project structure was created using Kotlin Multiplatform Web Wizard by terrakok
Inspirations:
Special thanks to:
This section lists planned improvements and features for the template. Contributions are welcome!
[ ] Add Detekt - Static code analysis for Kotlin
[ ] Add ktlint - Kotlin code formatter
[ ] Add Kover - Code coverage reporting
[ ] Add Dependabot - Automated dependency updates
[ ] Screenshot Testing - Visual regression testing
[ ] Performance Benchmarking - Track performance metrics
[ ] Multi-Module Support Guide
[ ] Compose Resources Guide
[ ] Video Tutorial
[ ] Blog Post / Article
[ ] Automated Changelog Generation
[ ] Version Bump Automation
[ ] Automated Screenshots
[ ] Linux Desktop Packaging
[ ] Additional Apple Platforms
[ ] Desktop Linux Native Build
[ ] Custom Domain Support
[ ] Improved Docsify Theme
[ ] Search Functionality
[ ] Vulnerability Scanning
[ ] SBOM Generation
[ ] Signed Releases
[ ] JitPack Support
[ ] NPM Package for Wasm
[ ] CocoaPods Support
If you have suggestions for improvements or want to contribute:
Priority: Items marked with 🔥 are high priority and would have the most impact.
See CONTRIBUTING.md for detailed contribution guidelines.