diff --git a/.gitignore b/.gitignore
index 9a5869273..63ecc6041 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,6 @@ Package.resolved
/packages/*/node_modules/
*.tgz
+
+packages/capacitor-native-ui/android/build/
+packages/capacitor-native-ui/android/.gradle/
diff --git a/.prettierignore b/.prettierignore
index e5e801b77..df05e14a5 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,6 @@ www
demo/ios/App/App/public
demo/playwright-report
demo/test-results
+
+packages/capacitor-native-ui/android/build/
+packages/capacitor-native-ui/android/.gradle/
diff --git a/README.md b/README.md
index a1de14c0f..78af4c6b1 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Bring the iOS look to your Ionic app, or add native controls to your Capacitor a
**[@rdlabo/capacitor-native-ui](packages/capacitor-native-ui/README.md)** projects supported Ionic controls into UIKit and SwiftUI on iOS. Your existing markup supplies their labels, icons, and state; native actions flow back to your Ionic components. Page content and routing stay in the WebView.
-Keep your existing Web appearance or pair it with the iOS theme. Native UI is opt-in and currently a preview; unsupported controls, Android, and browsers retain Web presentation. Your app or theme owns Web layout and styling.
+Keep your existing Web appearance or pair it with the iOS theme. Native UI is opt-in and currently a preview; unsupported controls and browsers retain Web presentation. On Android, standard controls stay on the Web; app-local extensions can render with Jetpack Compose. Your app or theme owns Web layout and styling.
[Get started with Native UI](packages/capacitor-native-ui/docs/setup.md) · [Supported controls](packages/capacitor-native-ui/docs/native-ui-shell.md#supported-markup)
@@ -36,7 +36,7 @@ The same Library screen with Native UI Shell off and on, captured while dragging
| Render Ionic controls natively with your existing Web appearance | [Set up Native UI](packages/capacitor-native-ui/docs/setup.md) |
| Use native buttons without Ionic | [HTML setup](packages/capacitor-native-ui/docs/framework-independent.md) |
| Add only a vertical rail to an Ionic app | [Vertical Bars setup](packages/theme-ios27/docs/iphone-duo-with-original-theme.md) |
-| Add custom SwiftUI content | [App-local extensions](packages/capacitor-native-ui/docs/native-ui-extensions.md) |
+| Add custom SwiftUI or Compose content | [App-local extensions](packages/capacitor-native-ui/docs/native-ui-extensions.md) |
Already using the iOS theme? [Add Native UI to your theme](packages/theme-ios27/docs/add-native-ui.md) as your next step.
diff --git a/demo/src/native-ui-html.spec.ts b/demo/src/native-ui-html.spec.ts
index 4b44c9a80..26d6a80ff 100644
--- a/demo/src/native-ui-html.spec.ts
+++ b/demo/src/native-ui-html.spec.ts
@@ -347,7 +347,7 @@ test('ionic.auto false preserves explicit ion-button rails, live toggles, and so
await vi.waitFor(() => expect(host.querySelectorAll('button')).toHaveLength(1));
});
-test('disabled automatic controls keep explicit native buttons and extensions eligible', async () => {
+test.each(['auto', 'extensions'] as const)('projection mode %s keeps only its eligible controls', async (source) => {
const { root, options } = mount(
'ExplicitAutomaticCounter',
);
@@ -375,16 +375,17 @@ test('disabled automatic controls keep explicit native buttons and extensions el
} as unknown as NativeUIShellPlugin;
const runtime = await createRuntime(document, plugin, {
...options,
- source: 'auto',
+ source,
explicitControls: true,
controls: { toolbar: false, tabs: false, segment: false, fab: false },
extensions: [{ name: 'counter', selector: '[data-counter]', read: () => ({ props: {} }) }],
});
handles.push(runtime);
- await vi.waitFor(() => expect(runtime.getStatus().projected).toBe(2));
- expect(snapshots.at(-1)!.controls.map((item) => item.kind)).toEqual(['button', 'extension']);
- expect(snapshots.at(-1)!.controls[0].items[0].label).toBe('Explicit');
- expect(root.querySelectorAll('[data-native-ui-shell]')).toHaveLength(2);
+ const expected = source === 'extensions' ? ['extension'] : ['button', 'extension'];
+ await vi.waitFor(() => expect(runtime.getStatus().projected).toBe(expected.length));
+ expect(snapshots.at(-1)!.controls.map((item) => item.kind)).toEqual(expected);
+ expect(root.querySelectorAll('[data-native-ui-shell]')).toHaveLength(expected.length);
+ if (source === 'extensions') expect(root.querySelector('ion-button')!.hasAttribute('data-native-ui-shell')).toBe(false);
});
test('explicit Ionic Web rails preserve shadow icons and observe artwork changes', async () => {
diff --git a/packages/capacitor-native-ui/README.md b/packages/capacitor-native-ui/README.md
index 0c236ce8e..0fe391fa2 100644
--- a/packages/capacitor-native-ui/README.md
+++ b/packages/capacitor-native-ui/README.md
@@ -1,8 +1,8 @@
# Capacitor Native UI
-Render supported Web controls with UIKit and SwiftUI on iOS while keeping your application state, routing, and handlers in the WebView. Use existing Ionic markup or explicit HTML buttons; custom SwiftUI content can be added through app-local extensions.
+Render supported Web controls with UIKit and SwiftUI on iOS while keeping your application state, routing, and handlers in the WebView. Use existing Ionic markup or explicit HTML buttons; custom SwiftUI or Android Compose content can be added through app-local extensions.
-Native UI is opt-in and currently a preview. Browsers, Android, older iOS, and unsupported controls retain Web presentation. Keep your application's styles and layout; the iOS theme is optional.
+Native UI is opt-in and currently a preview. Standard controls on Android, browsers, older iOS, and unsupported controls retain Web presentation. Android supports app-local Compose extensions. Keep your application's styles and layout; the iOS theme is optional.
## Choose what to build
@@ -12,13 +12,13 @@ Native UI is opt-in and currently a preview. Browsers, Android, older iOS, and u
| Use native buttons without Ionic | [HTML setup](docs/framework-independent.md) |
| Project only tabs and toolbar actions into a side rail | [Vertical Bars step by step](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/packages/theme-ios27/docs/iphone-duo-with-original-theme.md) |
| Add native controls to an existing iOS 27 theme app | [Add Native UI to your theme](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/packages/theme-ios27/docs/add-native-ui.md) |
-| Render custom SwiftUI content | [App-local extensions](docs/native-ui-extensions.md) |
+| Render custom SwiftUI or Android Compose content | [App-local extensions](docs/native-ui-extensions.md) |
## Requirements
-Native startup requires Capacitor 8 and an iOS project. Ordinary native rendering requires iOS 26+ and Xcode 26+. The Ionic adapter supports Ionic `>=8.8.1 <10`; the `/core` HTML adapter requires no Ionic or theme package. [Vertical Bars](docs/vertical-bars.md) and [extension placement](docs/native-ui-extensions.md#content-and-placement-are-independent) have additional device requirements.
+Native startup requires Capacitor 8 and an iOS or Android project. Android extensions require API 24+ and Kotlin/Compose. Ordinary native rendering requires iOS 26+ and Xcode 26+. The Ionic adapter supports Ionic `>=8.8.1 <10`; the `/core` HTML adapter requires no Ionic or theme package. [Vertical Bars](docs/vertical-bars.md) and [extension placement](docs/native-ui-extensions.md#content-and-placement-are-independent) have additional device requirements.
-Follow the setup guide for your markup before installing dependencies; the Ionic and HTML adapters use different imports.
+For standard controls, follow the setup guide for your markup; the Ionic and HTML adapters use different imports. For custom native content, start directly with the [extensions guide](docs/native-ui-extensions.md).
## Reference and examples
diff --git a/packages/capacitor-native-ui/android/build.gradle b/packages/capacitor-native-ui/android/build.gradle
index 6a0eb3434..2336630b1 100644
--- a/packages/capacitor-native-ui/android/build.gradle
+++ b/packages/capacitor-native-ui/android/build.gradle
@@ -6,16 +6,21 @@ ext {
}
buildscript {
+ ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '2.2.20'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
android {
namespace = "dev.rdlabo.capacitor.nativeui"
@@ -36,6 +41,8 @@ android {
lintOptions {
abortOnError = false
}
+ buildFeatures { compose true }
+ kotlinOptions { jvmTarget = '21' }
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
@@ -48,10 +55,15 @@ repositories {
}
dependencies {
+ api platform("androidx.compose:compose-bom:2025.10.00")
+ api "androidx.compose.ui:ui"
+ api "androidx.compose.foundation:foundation"
+ api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
testImplementation "junit:junit:$junitVersion"
+ testImplementation "org.json:json:20250517"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}
diff --git a/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.java b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.java
deleted file mode 100644
index 51f2e5ad4..000000000
--- a/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.rdlabo.capacitor.nativeui;
-
-import com.getcapacitor.Plugin;
-import com.getcapacitor.annotation.CapacitorPlugin;
-
-@CapacitorPlugin(name = "IonicNativeUIShell")
-public class IonicNativeUIShellPlugin extends Plugin {}
diff --git a/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.kt b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.kt
new file mode 100644
index 000000000..b54dff138
--- /dev/null
+++ b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.kt
@@ -0,0 +1,192 @@
+package dev.rdlabo.capacitor.nativeui
+
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.unit.LayoutDirection
+import com.getcapacitor.JSArray
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.util.UUID
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.math.roundToInt
+
+@CapacitorPlugin(name = "IonicNativeUIShell")
+class IonicNativeUIShellPlugin : Plugin() {
+ private val handler = Handler(Looper.getMainLooper())
+ private var host: FrameLayout? = null
+ private var revision = 0L
+ private var sequence = 0L
+ private var paused = false
+ private val entries = linkedMapOf()
+ private val pending = mutableMapOf Unit>()
+ private val layoutListener = View.OnLayoutChangeListener { _, l, t, r, b, oldL, oldT, oldR, oldB ->
+ if (l != oldL || t != oldT || r != oldR || b != oldB) {
+ host?.visibility = View.INVISIBLE
+ failActions()
+ notifyListeners("webViewMetricsChange", JSObject().put("radius", 0))
+ }
+ }
+
+ private class Entry(val view: ComposeView, var node: ExtensionNode) {
+ val content = mutableStateOf<(@Composable () -> Unit)?>(null)
+ }
+
+ override fun load() {
+ activity.runOnUiThread { bridge.webView.addOnLayoutChangeListener(layoutListener) }
+ }
+
+ @PluginMethod
+ fun configure(call: PluginCall) = onMain(call) {
+ clearViews()
+ revision = 0
+ sequence = 0
+ call.resolve(JSObject().put("supported", true).put("extensions", JSArray(NativeUIShell.renderers.keys.toList())))
+ }
+
+ @PluginMethod
+ fun getWebViewMetrics(call: PluginCall) = call.resolve(JSObject().put("radius", 0))
+
+ @PluginMethod
+ fun update(call: PluginCall) = onMain(call) {
+ val snapshot = ShellSnapshot.parse(call.data)
+ require(snapshot.revision > revision) { "Stale Native UI snapshot" }
+ val web = bridge.webView
+ val parent = web.parent as? ViewGroup ?: error("WebView has no host")
+ require(web.width > 0 && web.height > 0 && web.scaleX == 1f && web.scaleY == 1f) { "WebView geometry unavailable" }
+ val scale = web.width / snapshot.viewportWidth
+ val dpScale = scale / web.resources.displayMetrics.density
+ val accepted = linkedMapOf Unit>>()
+ val rejected = snapshot.rejected.toMutableList()
+ for (node in snapshot.nodes) {
+ val renderer = NativeUIShell.renderers[node.name]
+ if (renderer == null || paused) {
+ rejected.add(node.id)
+ continue
+ }
+ try {
+ val f = node.frame
+ require(listOf(f.x * scale, f.y * scale, f.width * scale, f.height * scale).all { it in -1_000_000.0..1_000_000.0 })
+ val context = NativeUIContext(node.id, f, NativeUIFrame(f.x * dpScale, f.y * dpScale, f.width * dpScale, f.height * dpScale), snapshot.viewportWidth, dpScale, node.dark, node.rtl)
+ val actions = NativeUIActions(node.actions.keys) { action -> activate(node, action) }
+ accepted[node.id] = node to renderer(JSONObject(node.props), actions, context)
+ } catch (_: Exception) {
+ rejected.add(node.id)
+ }
+ }
+ revision = snapshot.revision
+ val container = host ?: FrameLayout(activity).also {
+ it.clipChildren = true
+ it.clipToPadding = true
+ parent.addView(it, ViewGroup.LayoutParams(web.width, web.height))
+ host = it
+ }
+ container.layoutParams = container.layoutParams.apply { width = web.width; height = web.height }
+ container.x = web.x
+ container.y = web.y
+ for (id in entries.keys.toList()) if (id !in accepted) removeEntry(id)
+ for ((id, pair) in accepted) {
+ val (node, content) = pair
+ val entry = entries[id] ?: Entry(ComposeView(activity), node).also { entry ->
+ entry.view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ container.addView(entry.view)
+ entry.view.setContent { entry.content.value?.invoke() }
+ entries[id] = entry
+ }
+ entry.node = node
+ entry.content.value = {
+ CompositionLocalProvider(LocalLayoutDirection provides if (node.rtl) LayoutDirection.Rtl else LayoutDirection.Ltr) { content() }
+ }
+ entry.view.layoutParams = FrameLayout.LayoutParams((node.frame.width * scale).roundToInt().coerceAtLeast(1), (node.frame.height * scale).roundToInt().coerceAtLeast(1)).apply {
+ leftMargin = (node.frame.x * scale).roundToInt()
+ topMargin = (node.frame.y * scale).roundToInt()
+ }
+ }
+ container.visibility = if (paused) View.INVISIBLE else View.VISIBLE
+ call.resolve(JSObject().put("revision", revision).put("rejectedControls", JSArray(rejected)))
+ }
+
+ private suspend fun activate(node: ExtensionNode, name: String): Unit = withContext(Dispatchers.Main.immediate) {
+ check(!paused && host?.visibility == View.VISIBLE && entries[node.id]?.node == node) { "Native UI action is stale" }
+ val target = node.actions[name] ?: error("Native UI action is unavailable")
+ suspendCancellableCoroutine { continuation ->
+ val request = UUID.randomUUID().toString()
+ val timeout = Runnable { pending.remove(request)?.invoke(false) }
+ pending[request] = { accepted ->
+ handler.removeCallbacks(timeout)
+ if (continuation.isActive) {
+ if (accepted) continuation.resume(Unit)
+ else continuation.resumeWithException(IllegalStateException("Native UI action was not accepted"))
+ }
+ }
+ continuation.invokeOnCancellation { handler.post { pending.remove(request); handler.removeCallbacks(timeout) } }
+ handler.postDelayed(timeout, 5000)
+ notifyListeners("activate", JSObject().put("revision", revision).put("id", target).put("sequence", ++sequence).put("requestId", request))
+ }
+ }
+
+ @PluginMethod
+ fun completeExtensionAction(call: PluginCall) = onMain(call) {
+ val request = call.getString("requestId") ?: error("Missing requestId")
+ pending.remove(request)?.invoke(call.getBoolean("accepted", false) == true)
+ call.resolve()
+ }
+
+ @PluginMethod
+ fun clear(call: PluginCall) = onMain(call) {
+ val next = call.data.getLong("revision")
+ require(next > revision) { "Stale Native UI clear" }
+ revision = next
+ clearViews()
+ call.resolve()
+ }
+
+ private fun onMain(call: PluginCall, block: () -> Unit) {
+ activity.runOnUiThread {
+ try { block() } catch (error: Exception) { call.reject(error.message ?: "Native UI failed", error) }
+ }
+ }
+ private fun failActions() {
+ val callbacks = pending.values.toList()
+ pending.clear()
+ callbacks.forEach { it(false) }
+ }
+ private fun removeEntry(id: String) {
+ entries.remove(id)?.let { it.view.disposeComposition(); host?.removeView(it.view) }
+ }
+ private fun clearViews() {
+ failActions()
+ entries.keys.toList().forEach(::removeEntry)
+ (host?.parent as? ViewGroup)?.removeView(host)
+ host = null
+ }
+ override fun handleOnPause() {
+ paused = true
+ host?.visibility = View.INVISIBLE
+ failActions()
+ }
+ override fun handleOnResume() {
+ paused = false
+ notifyListeners("webViewMetricsChange", JSObject().put("radius", 0))
+ }
+ override fun handleOnDestroy() {
+ bridge.webView.removeOnLayoutChangeListener(layoutListener)
+ clearViews()
+ }
+}
diff --git a/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIShell.kt b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIShell.kt
new file mode 100644
index 000000000..7c4e40241
--- /dev/null
+++ b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIShell.kt
@@ -0,0 +1,48 @@
+package dev.rdlabo.capacitor.nativeui
+
+import android.os.Looper
+import androidx.compose.runtime.Composable
+import org.json.JSONObject
+import java.util.Collections
+
+/** App-local Compose renderers. Register on the main thread before starting the shell. */
+object NativeUIShell {
+ internal val renderers = linkedMapOf (@Composable () -> Unit)>()
+
+ fun register(
+ name: String,
+ decode: (JSONObject) -> Props,
+ content: @Composable (Props, NativeUIActions, NativeUIContext) -> Unit,
+ ) {
+ check(Looper.myLooper() == Looper.getMainLooper()) { "Register Native UI on the main thread" }
+ require(name.isNotBlank() && !renderers.containsKey(name)) { "Native UI names must be nonempty and unique" }
+ renderers[name] = { json, actions, context ->
+ val props = decode(json)
+ val view: @Composable () -> Unit = { content(props, actions, context) }
+ view
+ }
+ }
+}
+
+data class NativeUIFrame(val x: Double, val y: Double, val width: Double, val height: Double)
+
+/** Source geometry, not screen coordinates. Android currently supports element placement only. */
+data class NativeUIContext(
+ val sourceID: String,
+ val sourceFrame: NativeUIFrame,
+ val sourceFrameInDp: NativeUIFrame,
+ val viewportWidth: Double,
+ val dpPerCSSPixel: Double,
+ val dark: Boolean,
+ val rtl: Boolean,
+)
+
+/** Completion acknowledges the DOM click dispatch, not the application's asynchronous work. */
+class NativeUIActions internal constructor(names: Set, private val dispatch: suspend (String) -> Unit) {
+ val availableNames: Set = Collections.unmodifiableSet(names.toSet())
+ fun isAvailable(name: String): Boolean = name in availableNames
+ suspend fun activate(name: String) {
+ check(isAvailable(name)) { "Native UI action is unavailable" }
+ dispatch(name)
+ }
+}
diff --git a/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/ShellSnapshot.kt b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/ShellSnapshot.kt
new file mode 100644
index 000000000..fb67e5379
--- /dev/null
+++ b/packages/capacitor-native-ui/android/src/main/java/dev/rdlabo/capacitor/nativeui/ShellSnapshot.kt
@@ -0,0 +1,57 @@
+package dev.rdlabo.capacitor.nativeui
+
+import org.json.JSONObject
+
+internal data class ExtensionNode(
+ val id: String,
+ val frame: NativeUIFrame,
+ val dark: Boolean,
+ val rtl: Boolean,
+ val name: String,
+ val props: String,
+ val actions: Map,
+)
+
+internal data class ShellSnapshot(val revision: Long, val viewportWidth: Double, val nodes: List, val rejected: List) {
+ companion object {
+ fun parse(json: JSONObject): ShellSnapshot {
+ val revisionValue = json.getDouble("revision")
+ require(revisionValue.isFinite() && revisionValue >= 0 && revisionValue % 1.0 == 0.0 && revisionValue <= 9007199254740991.0)
+ val width = json.getDouble("viewportWidth")
+ require(width.isFinite() && width > 0)
+ val controls = json.getJSONArray("controls")
+ require(controls.length() <= 1000)
+ val ids = mutableSetOf()
+ val nodes = mutableListOf()
+ val rejected = mutableListOf()
+ for (index in 0 until controls.length()) {
+ val item = controls.getJSONObject(index)
+ val id = item.getString("id")
+ require(id.isNotBlank() && ids.add(id))
+ if (item.getString("kind") != "extension") {
+ rejected.add(id)
+ continue
+ }
+ val extension = item.getJSONObject("extension")
+ if (!extension.isNull("tabBarId") || item.optString("placement") == "vertical-bars") {
+ rejected.add(id)
+ continue
+ }
+ val frame = NativeUIFrame(item.getDouble("x"), item.getDouble("y"), item.getDouble("width"), item.getDouble("height"))
+ require(listOf(frame.x, frame.y, frame.width, frame.height).all { it.isFinite() })
+ require(frame.width > 0 && frame.height > 0)
+ val props = extension.getString("props")
+ require(props.length <= 65536)
+ JSONObject(props)
+ val rawActions = extension.getJSONObject("actions")
+ require(rawActions.length() <= 30)
+ val actions = rawActions.keys().asSequence().associateWith { key ->
+ require(key.isNotBlank())
+ rawActions.getString(key).also { require(it.isNotBlank()) }
+ }
+ nodes.add(ExtensionNode(id, frame, item.optBoolean("dark"), item.optBoolean("rtl"), extension.getString("name"), props, actions))
+ }
+ return ShellSnapshot(revisionValue.toLong(), width, nodes, rejected)
+ }
+ }
+}
diff --git a/packages/capacitor-native-ui/android/src/test/java/dev/rdlabo/capacitor/nativeui/ShellSnapshotTest.kt b/packages/capacitor-native-ui/android/src/test/java/dev/rdlabo/capacitor/nativeui/ShellSnapshotTest.kt
new file mode 100644
index 000000000..55a82f6d8
--- /dev/null
+++ b/packages/capacitor-native-ui/android/src/test/java/dev/rdlabo/capacitor/nativeui/ShellSnapshotTest.kt
@@ -0,0 +1,33 @@
+package dev.rdlabo.capacitor.nativeui
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+
+class ShellSnapshotTest {
+ private fun node() = JSONObject("""{"id":"counter","kind":"extension","x":12,"y":24,"width":100,"height":44,"extension":{"name":"app.counter","props":"{\"count\":1}","actions":{"increment":"button"}}}""")
+ private fun snapshot(vararg nodes: JSONObject) = JSONObject().put("revision", 1).put("viewportWidth", 360).put("controls", JSONArray(nodes.toList()))
+
+ @Test fun readsGeometryPropsAndActionTargets() {
+ val parsed = ShellSnapshot.parse(snapshot(node())).nodes.single()
+ assertEquals(NativeUIFrame(12.0, 24.0, 100.0, 44.0), parsed.frame)
+ assertEquals(1, JSONObject(parsed.props).getInt("count"))
+ assertEquals("button", parsed.actions["increment"])
+ }
+
+ @Test fun rejectsStandardControlsAndTabAccessories() {
+ val standard = JSONObject().put("id", "button").put("kind", "button")
+ val accessory = node().apply { getJSONObject("extension").put("tabBarId", "tabs") }
+ val parsed = ShellSnapshot.parse(snapshot(standard, accessory))
+ assertTrue(parsed.nodes.isEmpty())
+ assertEquals(listOf("button", "counter"), parsed.rejected)
+ }
+
+ @Test fun invalidSnapshotsDoNotPartiallyApply() {
+ assertThrows(IllegalArgumentException::class.java) { ShellSnapshot.parse(snapshot(node(), node())) }
+ assertThrows(IllegalArgumentException::class.java) { ShellSnapshot.parse(snapshot(node()).put("viewportWidth", 0)) }
+ assertThrows(IllegalArgumentException::class.java) { ShellSnapshot.parse(snapshot(node()).put("revision", 1.5)) }
+ assertThrows(IllegalArgumentException::class.java) { ShellSnapshot.parse(snapshot(node().put("width", -1))) }
+ }
+}
diff --git a/packages/capacitor-native-ui/docs/framework-independent.md b/packages/capacitor-native-ui/docs/framework-independent.md
index d6dac674c..6818f12d1 100644
--- a/packages/capacitor-native-ui/docs/framework-independent.md
+++ b/packages/capacitor-native-ui/docs/framework-independent.md
@@ -2,7 +2,7 @@
Add native buttons to an existing Web app without Ionic or theme CSS. Keep labels, state, and click handlers on ordinary HTML elements. Standard buttons require no custom Swift registration.
-Use a Capacitor 8 app with an iOS project, Xcode 26+, and an iOS 26+ device or simulator. Browsers and Android retain Web controls.
+Use a Capacitor 8 app with an iOS project, Xcode 26+, and an iOS 26+ device or simulator. Browsers and Android retain Web controls. For custom Android native content, use [Kotlin/Compose extensions](native-ui-extensions.md#2b-register-compose-content-on-android).
## 1. Install the plugin
diff --git a/packages/capacitor-native-ui/docs/migration.md b/packages/capacitor-native-ui/docs/migration.md
index dfab81da1..5055574d4 100644
--- a/packages/capacitor-native-ui/docs/migration.md
+++ b/packages/capacitor-native-ui/docs/migration.md
@@ -16,7 +16,7 @@ The theme and native controls now live in separate packages. Choose dependencies
| Native UI Shell or standalone Vertical Control Area | `@rdlabo/capacitor-native-ui` |
| Theme styling together with native controls | Both packages |
-The theme owns the appearance and behavior of Ionic's Web elements. Native UI owns projecting supported controls into native iOS views and coordinating their lifecycle with Web navigation. Native UI can be used without the iOS 27 theme; installing it does not apply theme styles. Android and browsers retain Web presentation.
+The theme owns the appearance and behavior of Ionic's Web elements. Native UI owns projecting supported controls into native iOS views and coordinating their lifecycle with Web navigation. Native UI can be used without the iOS 27 theme; installing it does not apply theme styles. Standard controls on Android and browsers retain Web presentation; Android can render [app-local Compose extensions](native-ui-extensions.md#2b-register-compose-content-on-android).
The theme uses the native package's shared transition/search integration internally. This dependency does not enable native controls automatically. Apps using native controls must declare the native package directly so Capacitor can discover the plugin.
diff --git a/packages/capacitor-native-ui/docs/native-ui-extensions.md b/packages/capacitor-native-ui/docs/native-ui-extensions.md
index 57646c29a..c06471350 100644
--- a/packages/capacitor-native-ui/docs/native-ui-extensions.md
+++ b/packages/capacitor-native-ui/docs/native-ui-extensions.md
@@ -4,11 +4,23 @@ title: App-local Native UI extensions
# App-local Native UI extensions
-An extension projects your existing Web UI into a SwiftUI View. Keep both definitions in your application: no npm package, separate library, or Capacitor plugin is required. The Web element remains the source of truth and the fallback UI.
+An extension projects your existing Web UI into a SwiftUI View on iOS or Jetpack Compose content on Android. Keep both definitions in your application: no npm package, separate library, or Capacitor plugin is required. The Web element remains the source of truth and the fallback UI.
## Before you start
-Complete [Ionic setup](setup.md) or [HTML setup](framework-independent.md) first. You need access to the iOS app target to add a SwiftUI View. The examples below use the Ionic entry; without Ionic, import both `defineNativeUI` and `enableNativeUIShell` from `@rdlabo/capacitor-native-ui/core`.
+Start with an existing Capacitor 8 app and install the plugin if you have not already:
+
+```sh
+npm install @rdlabo/capacitor-native-ui
+```
+
+Choose the native platform you want to support:
+
+- **Android:** use your Capacitor Android project and an API 24+ device or emulator. Follow step 1, then the [Kotlin/Compose registration](#2b-register-compose-content-on-android), and steps 3–4. No iOS project, Xcode, Ionic, or theme package is required.
+- **iOS:** use your Capacitor iOS project, Xcode 26+, and an iOS 26+ device or simulator. Follow step 1, the [SwiftUI registration](#2a-register-a-swiftui-view-on-ios), and steps 3–4. To also project standard controls, follow [Ionic setup](setup.md) or [HTML setup](framework-independent.md).
+- **Both:** share the DOM reader from step 1 and register native content for the same name on each platform.
+
+The TypeScript examples below use the Ionic entry. Without Ionic, import both `defineNativeUI` and `enableNativeUIShell` from `@rdlabo/capacitor-native-ui/core`.
## 1. Define a DOM reader
@@ -28,13 +40,13 @@ export const counter = defineNativeUI({
});
```
-For example, render `` with your framework. Its click handler updates the Web state and `data-count` attribute. The shell observes DOM changes and sends the new props to SwiftUI. State rendered into DOM attributes or text is observed automatically. For properties or external state read by the reader that do not mutate the DOM, call `shell.markForCheck()` after changing them.
+For example, render `` with your framework. Its click handler updates the Web state and `data-count` attribute. The shell observes DOM changes and sends the new props to the native content. State rendered into DOM attributes or text is observed automatically. For properties or external state read by the reader that do not mutate the DOM, call `shell.markForCheck()` after changing them.
The reader is synchronous and read-only. Return `null` when the source should stay on the Web. Props are a JSON object (finite numbers, strings, booleans, null, arrays and objects), limited to 64K characters. Action names map to the source itself or its descendants; missing, hidden and disabled targets cannot be activated. Up to 30 actions are supported per source.
-## 2. Register a SwiftUI View in the app target
+## 2a. Register a SwiftUI View on iOS
-Add a Swift file to the application's Xcode target. Import `IonicNativeUIShellPlugin`, the module supplied by `@rdlabo/capacitor-native-ui`. Follow the [Native UI Shell setup](native-ui-shell.md#enable-the-shell) first.
+Run `npx cap sync ios` after installing the plugin, then add a Swift file to the application's Xcode target. Import `IonicNativeUIShellPlugin`, the module supplied by `@rdlabo/capacitor-native-ui`. Keep your existing SPM or CocoaPods integration.
```swift
import SwiftUI
@@ -77,6 +89,63 @@ struct CounterView: View {
Call `registerAppNativeUI()` once from `application(_:didFinishLaunchingWithOptions:)`, before JavaScript enables the shell. Handle a registration error there; duplicate or empty names are rejected. Swift source is compiled into the app, so adding or changing a View requires an iOS rebuild. Registration is synchronous setup, not a native bridge command.
+## 2b. Register Compose content on Android
+
+Android supports **element extensions only**. Standard Ionic and HTML controls stay on the Web, including explicitly marked buttons. Vertical Bars and `tab-accessory` are iOS features. The same DOM reader, props, action targets, and `markForCheck()` work on both platforms.
+
+Run `npx cap sync android` after installing the plugin. Use Kotlin in your Android app. In the root `build.gradle`, add the Kotlin and Compose compiler plugins to `buildscript.dependencies`, using the same Kotlin version as the app (the plugin defaults to `2.2.20`). Define `ext.kotlin_version = '2.2.20'` in the root `buildscript` if it is not already defined:
+
+```groovy
+classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
+```
+
+In `app/build.gradle`, apply `kotlin-android` and `org.jetbrains.kotlin.plugin.compose`, and enable `android { buildFeatures { compose true } }`. The Native UI dependency exposes Compose UI and Foundation; add Material dependencies yourself if your extension uses them.
+
+Register once from your `Application.onCreate()`, on the main thread, before JavaScript enables the shell. Use an `Application` subclass configured with `android:name=".App"` on `` in `AndroidManifest.xml`; avoid registering on each Activity recreation.
+
+```kotlin
+import android.app.Application
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import dev.rdlabo.capacitor.nativeui.NativeUIShell
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+class App : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ NativeUIShell.register(
+ name = "app.counter",
+ decode = { json -> json.getInt("count") },
+ ) { count, actions, context ->
+ val scope = rememberCoroutineScope()
+ Box(Modifier.fillMaxSize().clickable(enabled = actions.isAvailable("increment")) {
+ scope.launch {
+ try {
+ actions.activate("increment")
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: IllegalStateException) {
+ // Display an application-appropriate error.
+ }
+ }
+ }) {
+ BasicText("Count: $count")
+ }
+ }
+ }
+}
+```
+
+The shell sizes and positions the Compose host from the source DOM rectangle. `context.sourceFrame` contains CSS pixels; `sourceFrameInDp` contains Compose dp, with `dpPerCSSPixel` as the conversion factor. `sourceID` and `viewportWidth` describe the current source and viewport. `rtl` is also applied through `LocalLayoutDirection`; use `dark` to select your app's Compose theme. Do not apply the source's `x` and `y` again inside your content.
+
+The `decode` callback runs before accepting the projection. If it throws, that source stays on the Web. Empty or duplicate registration names are rejected. Compose content owns its styling and accessibility. Rebuild the Android app after changing Kotlin code.
+
## 3. Enable with local definitions
```ts
@@ -92,15 +161,20 @@ Keep extension objects stable across repeated enable calls. Changing extensions
## 4. Build and try the extension
-Build your Web assets, run `npx cap sync ios`, and rebuild the iOS app in Xcode. On iOS 26+, the counter should render with SwiftUI. Tap Increment: the original Web handler should update `data-count`, and the native count should follow. In a browser, the original Web counter should still work.
+Build your Web assets, then sync and rebuild the platform you configured:
+
+- **Android:** run `npx cap sync android`, then build and launch the app from Android Studio. The counter should render with Compose.
+- **iOS:** run `npx cap sync ios`, then build and launch the app from Xcode. On iOS 26+, the counter should render with SwiftUI.
+
+Tap Increment: the original Web handler should update `data-count`, and the native count should follow. In a browser, the original Web counter should still work.
-If the source stays on the Web, confirm that the Swift file belongs to the app target, registration runs before startup, and the names and props agree on both sides.
+If the source stays on the Web, confirm that registration runs before JavaScript enables the shell and that the names and props agree on both sides. On Android, check that your `Application` subclass is declared in the manifest; on iOS, check that the Swift file belongs to the app target.
## Content and placement are independent
| `placement` | Behavior |
| --- | --- |
-| Omitted or `'element'` | Hosts the View in the source element's Web frame on iOS 26+. |
+| Omitted or `'element'` | Hosts native content in the source element's Web frame on iOS 26+ or Android API 24+. |
| `'tab-accessory'` | Attaches the View to a projected tab bar's system SwiftUI tab accessory on iOS 26.1+. |
For `tab-accessory`, place the source inside its owning `ion-tabs`. The shell resolves that tabs element's `ion-tab-bar`; applications do not pass native controller references. Currently only the **Vertical Bars SwiftUI tab host** supports this placement. Ordinary UIKit tab bars, missing native tabs, iOS 26.0, or multiple competing accessories keep the source on the Web. The shell does not approximate a system accessory with a floating overlay. Keep an equivalent Web layout (for example, a fixed footer); the source retains its layout space while hidden.
@@ -231,11 +305,11 @@ struct BadgeView: View {
## Lifecycle and action completion
-The shell owns discovery, geometry, state synchronization, native controller containment, overlay retirement, suspension and teardown. Extensions do not implement attach/detach hooks. Web sources are hidden only after native accepts the projection. An unregistered renderer, incompatible Swift props, unsupported placement or failed reader leaves that instance on the Web. Existing shell overlay policy applies; extensions return to the Web while Web overlays cover the page.
+The shell owns discovery, geometry, state synchronization, native controller containment, overlay retirement, suspension and teardown. Extensions do not implement attach/detach hooks. Web sources are hidden only after native accepts the projection. An unregistered renderer, a failing props decoder, unsupported placement or failed reader leaves that instance on the Web. Existing shell overlay policy applies; extensions return to the Web while Web overlays cover the page.
Element placement requires the source to fit fully inside the viewport and its ancestors' overflow clipping bounds, including Ionic's shadow scroll containers. Partially clipped sources stay on the Web and become eligible again when scrolled fully into view. Tab accessories use the system host's placement instead.
-`try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, Swift task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method.
+`try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, native task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method.
The [demo reader](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/demo/src/native-ui/counter.ts) and [demo SwiftUI View](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/demo/ios/App/App/NativeUICounter.swift) show both placements using app-local files. Open the Native UI Shell demo page to compare native projection with its Web fallback.
diff --git a/packages/capacitor-native-ui/docs/native-ui-shell.md b/packages/capacitor-native-ui/docs/native-ui-shell.md
index 5ebaf0e64..d5a6d7e58 100644
--- a/packages/capacitor-native-ui/docs/native-ui-shell.md
+++ b/packages/capacitor-native-ui/docs/native-ui-shell.md
@@ -2,7 +2,7 @@
title: Native UI Shell (Preview)
---
-For application-specific SwiftUI content, see [App-local Native UI extensions](native-ui-extensions.md).
+For application-specific SwiftUI or Compose content, see [App-local Native UI extensions](native-ui-extensions.md).
# Native UI Shell (Preview)
diff --git a/packages/capacitor-native-ui/docs/setup.md b/packages/capacitor-native-ui/docs/setup.md
index 0557e8c45..279d44649 100644
--- a/packages/capacitor-native-ui/docs/setup.md
+++ b/packages/capacitor-native-ui/docs/setup.md
@@ -94,7 +94,7 @@ Run the iOS app from Xcode on an iOS 26+ device or simulator. Supported fixed co
- [Vertical Bars only](https://github.com/rdlabo-dev/ionic-theme-ios27/blob/main/packages/theme-ios27/docs/iphone-duo-with-original-theme.md): a step-by-step rail setup with your existing theme.
- [Configuration](configuration.md): choose controls and change rail settings at runtime.
-- [Custom SwiftUI content](native-ui-extensions.md): create an app-local extension.
+- [Custom SwiftUI or Compose content](native-ui-extensions.md): create an app-local extension.
- [Migration](migration.md): update older package names, imports, and native projects.
## Navigation without the theme
diff --git a/packages/capacitor-native-ui/src/core.ts b/packages/capacitor-native-ui/src/core.ts
index e179c50b5..57a3c152f 100644
--- a/packages/capacitor-native-ui/src/core.ts
+++ b/packages/capacitor-native-ui/src/core.ts
@@ -1,4 +1,4 @@
-/** Standard native UI and app-local SwiftUI extensions. No Ionic runtime import. */
+/** Standard native UI and app-local SwiftUI and Compose extensions. No Ionic runtime import. */
export { enableNativeUIShell } from './native/startup';
export { defineNativeUI } from './native/extensions';
export type { NativeUIExtension, NativeUIValue, NativeUIReadContext, NativeUIImageReference } from './native/extensions';
diff --git a/packages/capacitor-native-ui/src/native/activation.ts b/packages/capacitor-native-ui/src/native/activation.ts
index 84b279175..1b7a8ca7b 100644
--- a/packages/capacitor-native-ui/src/native/activation.ts
+++ b/packages/capacitor-native-ui/src/native/activation.ts
@@ -220,13 +220,14 @@ export const enableNativeUIShell = (
// Native controls and DOM ownership are shared. A replacement activation
// must wait until the old handle has finished clearing both surfaces.
if (previousStop) await previousStop.catch(() => {});
- const stopModals = options.source === 'html' ? () => {} : observeVerticalBarsModals(document);
+ const platform = Capacitor.getPlatform();
+ const stopModals = platform === 'android' || options.source === 'html' ? () => {} : observeVerticalBarsModals(document);
const prehide =
- options.source !== 'html' && (options.controls === undefined || options.controls.toolbar === true)
+ platform !== 'android' && options.source !== 'html' && (options.controls === undefined || options.controls.toolbar === true)
? prehideVerticalBarsToolbarSources(document, () => options.verticalBars?.enabled !== false, options.root)
: undefined;
const fallback = (reason: string) =>
- manage(webProjection(document, options), {
+ manage(platform === 'android' ? web(reason) : webProjection(document, options), {
reason,
suspend: () => prehide?.suspend(),
destroy: () => {
@@ -235,14 +236,40 @@ export const enableNativeUIShell = (
},
stopping: stop,
});
- if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
+ if (platform !== 'ios' && platform !== 'android') return fallback('Requires Capacitor iOS or Android');
if (!Capacitor.isPluginAvailable('IonicNativeUIShell')) return fallback('Native UI plugin is not registered');
let runtime: NativeUIShellRuntimeHandle | undefined;
let overlays: OverlayController | undefined;
let metricsListener: Awaited> | undefined;
try {
const capabilities = await bounded(plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }));
- if (!capabilities.supported) return fallback('Requires iOS 26 or later');
+ if (!capabilities.supported) return fallback('Native UI is unavailable on this device');
+ if (platform === 'android') {
+ runtime = await createRuntime(
+ document,
+ plugin,
+ {
+ ...options,
+ source: 'extensions',
+ extensions: extensions.filter(
+ (extension) => extension.placement !== 'tab-accessory' && capabilities.extensions?.includes(extension.name),
+ ),
+ },
+ () => false,
+ options.verticalBarsOnly === true,
+ );
+ metricsListener = await boundedListener(
+ plugin.addListener('webViewMetricsChange', () => {
+ document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
+ }),
+ );
+ return manage(runtime, {
+ stopping: stop,
+ destroy: async () => {
+ if (metricsListener) await bounded(metricsListener.remove()).catch(() => {});
+ },
+ });
+ }
let deviceEdge: VerticalBarEdge = null;
const ionicRoot = () => {
if (options.source === 'html') return null;
diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts
index 320a9c32a..e39e60501 100644
--- a/packages/capacitor-native-ui/src/native/definitions.ts
+++ b/packages/capacitor-native-ui/src/native/definitions.ts
@@ -18,7 +18,7 @@ export interface NativeUIShellStatus {
}
export interface VerticalControlAreaOptions {
- /** App-local DOM readers with matching SwiftUI registrations. */
+ /** App-local DOM readers with matching SwiftUI or Compose registrations. */
extensions?: readonly NativeUIExtension[];
/** Opt-in popovers anchored to native Vertical Bars buttons. */
controls?: Pick;
@@ -56,7 +56,7 @@ export interface NativeUIShellOptions extends VerticalControlAreaOptions {
/** @internal Resolved adapter state; not an application configuration API. */
export interface NativeUIShellRuntimeOptions extends NativeUIShellOptions {
- source?: 'ionic' | 'html' | 'auto';
+ source?: 'ionic' | 'html' | 'auto' | 'extensions';
/** @internal Preserves legacy controls.buttons behavior for explicit sources. */
explicitControls?: boolean;
nativeState?: { edge?: 'left' | 'right' };
diff --git a/packages/capacitor-native-ui/src/native/extensions.ts b/packages/capacitor-native-ui/src/native/extensions.ts
index 8a4cacb18..d2e218535 100644
--- a/packages/capacitor-native-ui/src/native/extensions.ts
+++ b/packages/capacitor-native-ui/src/native/extensions.ts
@@ -15,7 +15,7 @@ export interface NativeUIReadContext {
image(element: SVGElement | HTMLElement | null, options?: { template?: boolean }): NativeUIImageReference | null;
}
-/** An app-local DOM projection. Swift registers its View under the same name. */
+/** An app-local DOM projection. Swift or Kotlin registers its native content under the same name. */
export interface NativeUIExtension {
readonly name: string;
readonly selector: string;
diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts
index c5d335503..9de1433f7 100644
--- a/packages/capacitor-native-ui/src/native/runtime.ts
+++ b/packages/capacitor-native-ui/src/native/runtime.ts
@@ -58,7 +58,13 @@ export const createRuntime = async (
const extensions = options.extensions ?? [];
const scope = options.root ?? doc;
const discovery = [
- options.source === 'html' ? htmlSelector : options.source === 'auto' ? `${selector}, ${htmlSelector}` : selector,
+ options.source === 'extensions'
+ ? ':not(*)'
+ : options.source === 'html'
+ ? htmlSelector
+ : options.source === 'auto'
+ ? `${selector}, ${htmlSelector}`
+ : selector,
...extensions.map((extension) => extension.selector),
].join(', ');
const icons = createIconRenderer();
@@ -188,6 +194,7 @@ export const createRuntime = async (
if (verticalBarsOnly && (!candidate?.control.extension?.tabBarId || !nativeVerticalBars())) return;
return candidate;
}
+ if (options.source === 'extensions') return;
if (options.source === 'html' || element.matches(htmlSelector)) {
const candidate = readHTMLControl(element, id, options);
if (!candidate) return;
@@ -365,7 +372,7 @@ export const createRuntime = async (
frame = 0;
dirty = false;
pending = true;
- syncToolbarText(doc);
+ if (options.source !== 'extensions') syncToolbarText(doc);
handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil;
try {
// A retained sync performs no update; keep the modal handoff armed so the
diff --git a/scripts/verify-packages.mjs b/scripts/verify-packages.mjs
index 3ed9a4ac9..69db4a22a 100644
--- a/scripts/verify-packages.mjs
+++ b/scripts/verify-packages.mjs
@@ -88,7 +88,7 @@ try {
assert(files.has('android/proguard-rules.pro'));
assert(files.has('IonicThemeCapacitorNativeUi.podspec'));
assert(files.has('ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift'));
- assert(files.has('android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.java'));
+ assert(files.has('android/src/main/java/dev/rdlabo/capacitor/nativeui/IonicNativeUIShellPlugin.kt'));
assert(!manifest.dependencies?.['@rdlabo/ionic-theme-ios27'], 'Native UI must not depend on the theme');
assert(!files.has('dist/native-integration/transition.js'), 'Ionic transition adapter belongs to the theme');
} else {