diff --git a/CHANGELOG.md b/CHANGELOG.md index 9306674..313060d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev). ## [Unreleased] +### Fixed +- **Android plugin component registration is backward-compatible again.** + `ui_components.android.composable` remains the native registry key used by + existing plugin bridges instead of being treated as a callable Kotlin + symbol. Generated registration is now explicit through `android.factory`; + opted-in factories receive both `props` and the native event sender. The + generated factories run before bridge registration so a bridge-owned + factory remains authoritative for the same key. + ## [0.6.31] - 2026-08-31 ### Fixed diff --git a/decisions/2026-08-30-android-ui-components-bootstrap.md b/decisions/2026-08-30-android-ui-components-bootstrap.md index 78de383..3a1e244 100644 --- a/decisions/2026-08-30-android-ui-components-bootstrap.md +++ b/decisions/2026-08-30-android-ui-components-bootstrap.md @@ -1,7 +1,8 @@ # Android ui_components registration rides the generated MobPluginBootstrap - Date: 2026-08-30 -- Status: accepted +- Status: superseded by + [2026-08-31-explicit-android-component-factories.md](2026-08-31-explicit-android-component-factories.md) ## Context diff --git a/decisions/2026-08-31-explicit-android-component-factories.md b/decisions/2026-08-31-explicit-android-component-factories.md new file mode 100644 index 0000000..ecaffde --- /dev/null +++ b/decisions/2026-08-31-explicit-android-component-factories.md @@ -0,0 +1,34 @@ +# Android component factory generation is explicit + +- Date: 2026-08-31 +- Status: accepted + +## Context + +MobDev 0.6.31 treated `ui_components.android.composable` as a callable Kotlin +symbol. Existing plugins and MobDev's own tier-2 scaffold use that field as the +native-view registry key. Some plugins register their factories from +`android.bridge_class`, including factories that depend on the native event +sender. The generated 0.6.31 registration therefore either failed to compile +or overwrote a working bridge-owned factory with one that discarded events. + +## Decision + +`android.composable` retains its established registry-key meaning. A component +opts into generated registration with a separate `android.factory` Kotlin +function. The function accepts `(props, send)`; generated code forwards both +arguments. A bare function name is qualified with the package of +`android.bridge_class`, while a fully-qualified name needs no bridge. + +Generated factories are installed before bridge `register()` and activity +handoff. A bridge that registers the same key therefore remains authoritative. +Components without `android.factory` receive no generated registration and +continue using their existing bridge- or host-owned path. + +## Consequences + +- Existing manifests and eventful bridge registrations work unchanged. +- Automatic registration is deliberate and has one event-capable signature. +- Plugins adopting automatic registration may ignore `send` in their Kotlin + function, but the function must accept it. +- MobDev 0.6.31 should be skipped in favor of the containing patch release. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 94bbaef..78fcb51 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -5058,9 +5058,11 @@ defmodule MobDev.NativeBuild do # Copies each activated plugin's `bridge_kt` into the app's Kotlin sourceSet # (at its own package path, read from the file's `package` line) so Gradle # compiles it, and (re)generates `io.mob.plugin.MobPluginBootstrap` whose - # `registerAll(activity)` calls each `bridge_class`'s `register()`, hands - # the Activity to any bridge implementing `MobActivityAware`, and registers - # the plugins' `ui_components` Compose factories with the app's + # `registerAll(activity)` registers opted-in `ui_components` Compose + # factories with the app's MobNativeViewRegistry, then calls each + # `bridge_class`'s `register()` and hands the Activity to any bridge + # implementing `MobActivityAware`. Bridge-owned registrations run last and + # remain authoritative. # MobNativeViewRegistry (MobDev.Plugin.AndroidBootstrap — the Android # analog of the iOS mob_register_plugins bootstrap; see mob_scene3d-q03). # MainActivity calls `MobPluginBootstrap.registerAll(this)` in `onCreate`. @@ -5132,11 +5134,9 @@ defmodule MobDev.NativeBuild do end end - # Resolves the ui_components half of the bootstrap for the activated - # plugins: classify the manifests (pure), raise on malformed declarations - # (an android-backed component codegen can't register is a manifest bug — - # surface it at build time, next to the manifest, not as a blank view on - # device), and locate the app package that defines MobNativeViewRegistry. + # Resolves explicitly opted-in ui_components factories for the activated + # plugins: classify the manifests, raise on malformed opt-ins, and locate + # the app package that defines MobNativeViewRegistry. # Hosts without the registry (LiveView wrappers, pre-registry templates) # can't render native views at all, so declared ui_components get a printed # warning and no generated registrations there. @@ -5150,14 +5150,13 @@ defmodule MobDev.NativeBuild do ) end - case {classified.registrations ++ classified.placeholders, - __android_app_package__(@android_java_root)} do + case {classified.registrations, __android_app_package__(@android_java_root)} do {[], _} -> nil {_some, nil} -> IO.puts( - " [plugin android] activated plugins declare ui_components but no " <> + " [plugin android] activated plugins declare android.factory but no " <> "MobNativeViewRegistry was found under #{@android_java_root} " <> "(MobBridge.kt) — skipping Compose factory registration. Native " <> "view components will not render in this host." @@ -5264,7 +5263,7 @@ defmodule MobDev.NativeBuild do ui_body = if ui, do: ui.body, else: "" calls = - [bridge_calls, ui_call] + [ui_call, bridge_calls] |> Enum.reject(&(&1 == "")) |> Enum.join("\n") @@ -5295,10 +5294,9 @@ defmodule MobDev.NativeBuild do """ // Generated by mob_dev (MobDev.NativeBuild) — do not edit. - // Calls each activated plugin's bridge-class register() at startup, then - // hands the Activity to any bridge implementing MobActivityAware and records - // any bridge implementing MobPermissionProvider; also registers the plugins' - // ui_components Compose factories with the app's MobNativeViewRegistry. + // Registers explicitly opted-in ui_components Compose factories, then calls + // each activated plugin's bridge-class register() and hands the Activity to + // bridges implementing MobActivityAware. Bridge-owned factories run last. // Invoked from MainActivity.onCreate as registerAll(this), before // setContent renders anything. package io.mob.plugin diff --git a/lib/mob_dev/plugin/android_bootstrap.ex b/lib/mob_dev/plugin/android_bootstrap.ex index d995d56..6751923 100644 --- a/lib/mob_dev/plugin/android_bootstrap.ex +++ b/lib/mob_dev/plugin/android_bootstrap.ex @@ -1,71 +1,31 @@ defmodule MobDev.Plugin.AndroidBootstrap do @moduledoc """ - Code-generates the Android `ui_components` registrations spliced into the - generated `io.mob.plugin.MobPluginBootstrap` — the Android analog of - `MobDev.Plugin.IOSBootstrap`. - - Before this module existed the manifest's `ui_components.android` entry was - data nobody consumed: every host had to hand-register the plugin's Compose - factory in `MainActivity.onCreate`, and a host that forgot rendered the - component as *nothing* — `MobNativeViewRegistry.render` returns silently on - an unknown key (mob_scene3d-q03, the chopaat repro). Now - `MobPluginBootstrap.registerAll(this)` — which every generated/adopted - MainActivity already calls before `setContent` — also registers the - activated plugins' composables, so a declared component either works or - fails loudly: - - * **Resolvable entries are auto-registered.** The registry key comes from - `ui_components.android.view_module`, falling back to - `ui_components.ios.view_module` (both platforms share the key — it is - the Elixir module name with dots → underscores, what the BEAM sends as - the node's `module` prop). The Compose factory is - `ui_components.android.composable`: used as-is when fully qualified - (contains a `.`), otherwise qualified with the package of - `android.bridge_class` (the composable ships in the plugin's - `bridge_kt`, which declares that package). A typo'd composable fails - the Gradle Kotlin compile — loud, at build time. - - * **Unresolvable-but-declared entries get a loud placeholder.** A bare - `composable` with no `bridge_class` to derive a package from (the - hand-copied tier-2 workflow, where the host pastes the factory into its - own source) registers a placeholder that renders a red - "Missing native component" tile and logs an error. A host that follows - the documented workflow — registering the real factory in - `MainActivity.onCreate` *after* `registerAll(this)` — overwrites the - placeholder; a host that forgot sees the tile instead of silence. - - * **Malformed entries fail the build.** An android-backed component with - no resolvable registry key (or no `composable` at all) is returned in - `:errors`; `MobDev.NativeBuild` raises with the message. The manifest is - the bug, so build time — next to the manifest — is where it surfaces. - - `MobNativeViewRegistry` lives in the *app* package (MobBridge.kt), which - `io.mob.plugin` code cannot import by name at authoring time — the reason a - plugin bridge's own `register()` can't do this. Codegen can: the caller - passes the discovered app package and every reference is emitted fully - qualified. - - Pure, no I/O: `classify/1` takes the activated-plugin list - (`[{plugin_dir, manifest}]`, the `MobDev.Plugin.activated/0` shape) and - `ui_source/2` renders the Kotlin. Output order is activation order, then - declaration order within a manifest — stable output keeps builds - reproducible. + Generates opt-in Android `ui_components` registrations for + `io.mob.plugin.MobPluginBootstrap`. + + `ui_components.android.composable` remains the native-view registry key used + by existing plugins. A plugin opts into generated registration by declaring + a separate `ui_components.android.factory` Kotlin function. Generated + factories receive both the component props and native event sender. + + A bare factory name is qualified with the package of + `android.bridge_class`; a fully-qualified factory works without a bridge. + Plugins that do not declare `factory` keep their existing bridge- or + host-owned registration unchanged. """ alias MobDev.Plugin.Merge @type classified :: %{ - registrations: [%{key: String.t(), composable: String.t(), plugin: atom()}], - placeholders: [%{key: String.t(), plugin: atom()}], + registrations: [%{key: String.t(), factory: String.t(), plugin: atom()}], errors: [String.t()] } @doc """ - Buckets the activated plugins' android-backed `ui_components` into - auto-registrations, loud placeholders, and build errors (see moduledoc). + Classifies explicitly auto-registered Android components. - Components without an `:android` map (iOS-only) contribute nothing here — - the validator's single-platform warning is what nags about those. + Components without `android.factory` contribute nothing. Output order is + activation order followed by declaration order within each manifest. """ @spec classify([Merge.plugin()]) :: classified() def classify(plugins) do @@ -75,67 +35,61 @@ defmodule MobDev.Plugin.AndroidBootstrap do component <- Map.get(manifest, :ui_components, []), is_map(component), android = component[:android], - is_map(android) do + is_map(android), + Map.has_key?(android, :factory) do classify_component(component, android, manifest) end %{ - registrations: for({:registration, r} <- buckets, do: r), - placeholders: for({:placeholder, p} <- buckets, do: p), - errors: for({:error, e} <- buckets, do: e) + registrations: for({:registration, registration} <- buckets, do: registration), + errors: for({:error, error} <- buckets, do: error) } end defp classify_component(component, android, manifest) do plugin = manifest[:name] key = registry_key(component) - composable = android[:composable] + factory = android[:factory] bridge_pkg = bridge_package(manifest) cond do - not is_binary(key) -> + not is_binary(factory) -> {:error, "plugin #{inspect(plugin)}: ui_components #{component_label(component)} declares " <> - ":android backing but no registry key — add android.view_module (or " <> - "ios.view_module; both default to the Elixir module name with dots → " <> - "underscores, e.g. \"Mob_Scene3d_Viewport\") so the generated " <> - "MobPluginBootstrap can register the composable"} + "android.factory #{inspect(factory)}; expected a Kotlin identifier or dotted path"} - not is_binary(composable) -> + not is_binary(key) -> {:error, "plugin #{inspect(plugin)}: ui_components #{component_label(component)} declares " <> - ":android backing but no :composable — name the @Composable factory " <> - "(fully qualified, or bare when the plugin ships a bridge_class in the " <> - "same package) so the generated MobPluginBootstrap can register it"} + "android.factory #{inspect(factory)} but no registry key — add " <> + "android.view_module, android.composable, or ios.view_module"} - String.contains?(composable, ".") -> - {:registration, %{key: key, composable: composable, plugin: plugin}} + String.contains?(factory, ".") -> + {:registration, %{key: key, factory: factory, plugin: plugin}} is_binary(bridge_pkg) -> - {:registration, %{key: key, composable: "#{bridge_pkg}.#{composable}", plugin: plugin}} + {:registration, %{key: key, factory: "#{bridge_pkg}.#{factory}", plugin: plugin}} true -> - {:placeholder, %{key: key, plugin: plugin}} + {:error, + "plugin #{inspect(plugin)}: android.factory #{inspect(factory)} for " <> + "ui_components #{component_label(component)} must be fully qualified when the " <> + "plugin has no android.bridge_class"} end end - # Registry key both platforms share; android.view_module wins so an - # Android-only plugin needs no :ios map. defp registry_key(component) do - case get_in(component, [:android, :view_module]) do - key when is_binary(key) -> key - _ -> get_in(component, [:ios, :view_module]) + android = component[:android] + + cond do + is_binary(android[:composable]) -> android[:composable] + is_binary(android[:view_module]) -> android[:view_module] + true -> get_in(component, [:ios, :view_module]) end end - defp component_label(component) do - inspect(component[:atom] || component[:tag] || component) - end + defp component_label(component), do: inspect(component[:atom] || component[:tag] || component) - # Package of android.bridge_class ("io.mob.scene3d.MobScene3dBridge" → - # "io.mob.scene3d") — where a bare :composable lives, since it ships in the - # plugin's bridge_kt (which declares that package). A dotless bridge_class - # (default package) yields nil: nothing to qualify with. defp bridge_package(manifest) do with cls when is_binary(cls) <- get_in(manifest, [:android, :bridge_class]), parts when parts != [] <- cls |> String.split(".") |> Enum.drop(-1) do @@ -146,89 +100,30 @@ defmodule MobDev.Plugin.AndroidBootstrap do end @doc """ - Kotlin for the ui_components half of `MobPluginBootstrap`, or `nil` when - there is nothing to register (so plugin-less and UI-less builds emit a - byte-identical bootstrap to before this feature). - - Returns `%{call:, body:}` — `call` is the statement `registerAll` runs, - `body` the member functions spliced into the object. `app_package` is the - host app's Kotlin package (where MobBridge.kt defines - `MobNativeViewRegistry`); every registry reference is emitted fully - qualified against it. + Returns the generated Kotlin registration call and object body, or `nil`. """ @spec ui_source(classified(), String.t()) :: %{call: String.t(), body: String.t()} | nil - def ui_source(classified, app_package) do - lines = - Enum.map(classified.registrations, ®istration_kotlin(&1, app_package)) ++ - Enum.map(classified.placeholders, &placeholder_kotlin(&1, app_package)) + def ui_source(%{registrations: []}, _app_package), do: nil - if lines == [] do - nil - else - %{call: "registerUiComponents()", body: ui_body(lines, classified.placeholders)} - end + def ui_source(%{registrations: registrations}, app_package) do + lines = Enum.map(registrations, ®istration_kotlin(&1, app_package)) + %{call: "registerUiComponents()", body: ui_body(lines)} end - # Unused lambda params are `_` — Kotlin warns on named-but-unused ones. - defp registration_kotlin(%{key: key, composable: composable, plugin: plugin}, app_package) do + defp registration_kotlin(%{key: key, factory: factory, plugin: plugin}, app_package) do """ // #{plugin}: #{key} - #{app_package}.MobNativeViewRegistry.register(\"#{key}\") { props, _ -> - #{composable}(props) - } - """ - |> String.trim_trailing() - end - - defp placeholder_kotlin(%{key: key, plugin: plugin}, app_package) do - """ - // #{plugin}: #{key} — composable not resolvable from the manifest - // (bare :composable, no bridge_class package to qualify it with). - // The host's own MainActivity registration (after registerAll) - // overwrites this loud placeholder. - #{app_package}.MobNativeViewRegistry.register(\"#{key}\") { _, _ -> - MissingUiComponent(\"#{key}\", \"#{plugin}\") + #{app_package}.MobNativeViewRegistry.register(\"#{key}\") { props, send -> + #{factory}(props, send) } """ |> String.trim_trailing() end - defp ui_body(lines, placeholders) do - register_fun = - "\n\n // Registers the activated plugins' ui_components Compose factories\n" <> - " // with the app's MobNativeViewRegistry (generated from each plugin\n" <> - " // manifest — the Android analog of iOS's mob_register_plugins()).\n" <> - " private fun registerUiComponents() {\n" <> - Enum.join(lines, "\n") <> - "\n }" - - register_fun <> if placeholders == [], do: "", else: missing_component_kotlin() - end - - # The loud placeholder: visible red tile + error log instead of the silent - # nothing MobNativeViewRegistry.render produces for an unknown key. - defp missing_component_kotlin do - """ - - - // Loud placeholder for a declared ui_component whose Compose factory - // codegen could not resolve. Renders red and logs instead of nothing. - @androidx.compose.runtime.Composable - private fun MissingUiComponent(key: String, plugin: String) { - android.util.Log.e( - "MobPluginBootstrap", - "ui_component \\"$key\\" (plugin $plugin) mounted with no registered Compose " + - "factory — register it in MainActivity.onCreate after " + - "MobPluginBootstrap.registerAll(this), or declare a fully-qualified " + - "android.composable (or an android.bridge_class in the composable's " + - "package) in the plugin manifest." - ) - androidx.compose.material3.Text( - text = "Missing native component: $key ($plugin)", - color = androidx.compose.ui.graphics.Color.Red - ) - } - """ - |> String.trim_trailing("\n") + defp ui_body(lines) do + "\n\n // Registers explicitly opted-in plugin ui_components factories.\n" <> + " private fun registerUiComponents() {\n" <> + Enum.join(lines, "\n") <> + "\n }" end end diff --git a/lib/mob_dev/plugin/scaffold.ex b/lib/mob_dev/plugin/scaffold.ex index e31a45b..6818134 100644 --- a/lib/mob_dev/plugin/scaffold.ex +++ b/lib/mob_dev/plugin/scaffold.ex @@ -506,14 +506,10 @@ defmodule MobDev.Plugin.Scaffold do """ // #{mod} — tier-2 plugin Compose factory. // - // A plugin that ships its composable in a bridge_kt (with a bridge_class - // in the manifest) gets it registered automatically by the generated - // MobPluginBootstrap. This scaffold has no bridge, so the host app - // developer copies this content into MobBridge.kt (alongside the - // MobNativeViewRegistry definition) and calls #{mod}Plugin.register() in - // MainActivity.onCreate AFTER MobPluginBootstrap.registerAll(this) — the - // real factory then replaces the loud "missing component" placeholder the - // bootstrap registers for a declared-but-unresolvable composable. + // The host app developer copies this content into MobBridge.kt (alongside + // the MobNativeViewRegistry definition) and arranges + // #{mod}Plugin.register() to run at startup — the documented workflow for + // native components today. object #{mod}Plugin { fun register() { diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex index 63a241b..7f9e858 100644 --- a/lib/mob_dev/plugin/validator.ex +++ b/lib/mob_dev/plugin/validator.ex @@ -240,8 +240,8 @@ defmodule MobDev.Plugin.Validator do [ {"component atom (ui_components.atom)", &component_atoms/1}, {"iOS native view key (ui_components.ios.view_module)", &component_view_modules/1}, - {"Android native view key (ui_components.android.composable)", - &component_composables/1} + {"Android native view key (ui_components.android.composable/fallback)", + &component_android_view_modules/1} ]}, migrations: {:collision, [{"migration repo_namespace", &repo_namespaces/1}]}, nifs: {:collision, [{"NIF module (nifs.module)", &nif_modules/1}]}, @@ -422,13 +422,9 @@ defmodule MobDev.Plugin.Validator do "the iOS bootstrap codegen instantiates it as `(props: props)`" end - # ui_components.android.composable names the Compose factory the Android - # bootstrap codegen registers (`(props)`), pasted straight into - # the generated MobPluginBootstrap. It must be a Kotlin identifier, or a - # dotted fully-qualified one (`io.mob.scene3d.MobScene3dViewport`) when the - # composable's package differs from the bridge_class's. Catch a bad value at - # validate time rather than at Gradle time, where the Kotlin error is far - # from the manifest that produced it. + # ui_components.android.factory opts into generated registration and names + # the Kotlin function pasted into MobPluginBootstrap. `composable` retains + # its established role as the registry key. @kotlin_composable_pattern ~r/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/ defp add_composable_errors(result, manifest) when is_map(manifest) do @@ -437,8 +433,8 @@ defmodule MobDev.Plugin.Validator do is_map(c), android = c[:android], is_map(android), - Map.has_key?(android, :composable), - err = composable_error(android[:composable]), + Map.has_key?(android, :factory), + err = composable_error(android[:factory]), do: err %{result | errors: result.errors ++ errs} @@ -455,10 +451,10 @@ defmodule MobDev.Plugin.Validator do defp composable_error(other), do: bad_composable_message(other) defp bad_composable_message(value) do - "ui_components.android.composable #{inspect(value)} must be a Kotlin " <> + "ui_components.android.factory #{inspect(value)} must be a Kotlin " <> "identifier or dotted path matching /^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$/ " <> "(e.g. \"MobScene3dViewport\" or \"io.mob.scene3d.MobScene3dViewport\") — " <> - "the Android bootstrap codegen registers it as `(props)`" + "the Android bootstrap codegen invokes it as `(props, send)`" end defp add_swift_import_errors(result, manifest, plugin_dir) do @@ -615,13 +611,23 @@ defmodule MobDev.Plugin.Validator do do: ios[:view_module] end - defp component_composables(manifest) do + defp component_android_view_modules(manifest) do for c <- Map.get(manifest, :ui_components, []), is_map(c), android = c[:android], is_map(android), - is_binary(android[:composable]), - do: android[:composable] + key = android_registry_key(c, android), + is_binary(key), + do: key + end + + defp android_registry_key(component, android) do + cond do + is_binary(android[:composable]) -> android[:composable] + is_binary(android[:view_module]) -> android[:view_module] + Map.has_key?(android, :factory) -> get_in(component, [:ios, :view_module]) + true -> nil + end end defp screen_routes(manifest) do diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 64ee921..f87d6d8 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -465,24 +465,24 @@ defmodule MobDev.NativeBuildTest do call: "registerUiComponents()", body: "\n\n private fun registerUiComponents() {\n" <> - " com.example.app.MobNativeViewRegistry.register(\"Mob_Scene3d_Viewport\") { props, _send ->\n" <> - " io.mob.scene3d.MobScene3dViewport(props)\n }\n }" + " com.example.app.MobNativeViewRegistry.register(\"Mob_Scene3d_Viewport\") { props, send ->\n" <> + " io.mob.scene3d.MobScene3dViewport(props, send)\n }\n }" } src = NativeBuild.__bootstrap_kotlin__(["io.mob.scene3d.MobScene3dBridge"], ui) - # registerAll runs the bridge register()s first, then the UI half, so - # every Compose factory is registered before MainActivity's setContent. + # Auto-registered factories run first. A plugin bridge that owns the same + # key therefore remains authoritative when register()/handOff() follows. assert src =~ "io.mob.scene3d.MobScene3dBridge.register()" assert src =~ "registerUiComponents()" register_pos = :binary.match(src, "MobScene3dBridge.register()") |> elem(0) ui_pos = :binary.match(src, "registerUiComponents()") |> elem(0) - assert register_pos < ui_pos + assert ui_pos < register_pos assert src =~ "private fun registerUiComponents()" assert src =~ ~s|com.example.app.MobNativeViewRegistry.register("Mob_Scene3d_Viewport")| - assert src =~ "io.mob.scene3d.MobScene3dViewport(props)" + assert src =~ "io.mob.scene3d.MobScene3dViewport(props, send)" end test "__bootstrap_kotlin__ registers ui_components even with zero bridge classes, without bridge helpers" do diff --git a/test/mob_dev/plugin/android_bootstrap_test.exs b/test/mob_dev/plugin/android_bootstrap_test.exs index ce520e5..212cf35 100644 --- a/test/mob_dev/plugin/android_bootstrap_test.exs +++ b/test/mob_dev/plugin/android_bootstrap_test.exs @@ -6,9 +6,7 @@ defmodule MobDev.Plugin.AndroidBootstrapTest do defp base(extra), do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) - # mob_scene3d's real shape: bare composable + bridge_class in the same - # package, registry key on the iOS side only (mob_scene3d-q03 repro). - defp scene3d_manifest do + defp auto_registered_manifest do base(%{ name: :mob_scene3d, ui_components: [ @@ -16,7 +14,10 @@ defmodule MobDev.Plugin.AndroidBootstrapTest do tag: "Scene3d", atom: :scene3d, ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, - android: %{composable: "MobScene3dViewport"} + android: %{ + composable: "Mob_Scene3d_Viewport", + factory: "MobScene3dViewport" + } } ], android: %{bridge_class: "io.mob.scene3d.MobScene3dBridge"} @@ -24,117 +25,153 @@ defmodule MobDev.Plugin.AndroidBootstrapTest do end describe "classify/1" do - test "qualifies a bare composable with the bridge_class package" do - %{registrations: [reg], placeholders: [], errors: []} = - AndroidBootstrap.classify([{"/a", scene3d_manifest()}]) + test "preserves legacy composable registry keys without auto-registering them" do + manifest = + base(%{ + ui_components: [ + %{ + atom: :button, + android: %{composable: "ClarityUI_Components_Button_Native"} + } + ], + android: %{bridge_class: "io.example.ui.Plugin"} + }) + + assert AndroidBootstrap.classify([{"/a", manifest}]) == + %{registrations: [], errors: []} + end + + test "qualifies an opted-in bare factory with the bridge_class package" do + %{registrations: [reg], errors: []} = + AndroidBootstrap.classify([{"/a", auto_registered_manifest()}]) assert reg == %{ key: "Mob_Scene3d_Viewport", - composable: "io.mob.scene3d.MobScene3dViewport", + factory: "io.mob.scene3d.MobScene3dViewport", plugin: :mob_scene3d } end - test "uses a fully-qualified composable as-is (no bridge_class needed)" do + test "uses a fully-qualified factory as-is without a bridge_class" do manifest = base(%{ ui_components: [ %{ atom: :chart, - ios: %{view_module: "Mob_Chart"}, - android: %{composable: "io.mob.chart.MobChart"} + android: %{ + composable: "Mob_Chart", + factory: "io.mob.chart.MobChart" + } } ] }) - %{registrations: [reg], placeholders: [], errors: []} = + %{registrations: [reg], errors: []} = AndroidBootstrap.classify([{"/a", manifest}]) - assert reg.composable == "io.mob.chart.MobChart" + assert reg.factory == "io.mob.chart.MobChart" end - test "android.view_module wins over ios.view_module as the registry key" do + test "composable remains authoritative when view_module is also present" do manifest = base(%{ ui_components: [ %{ atom: :chart, - ios: %{view_module: "Ios_Key"}, - android: %{view_module: "Android_Key", composable: "io.mob.chart.MobChart"} + android: %{ + view_module: "Android_Key", + composable: "Legacy_Key", + factory: "io.mob.chart.MobChart" + } } ] }) %{registrations: [reg]} = AndroidBootstrap.classify([{"/a", manifest}]) - assert reg.key == "Android_Key" + assert reg.key == "Legacy_Key" end - test "bare composable without a bridge_class becomes a loud placeholder" do - # The hand-copied tier-2 workflow: the manifest can't tell codegen where - # the composable lives, so the host registers it by hand — and gets a - # loud placeholder (not silence) if it forgets. + test "falls back to ios.view_module when composable is absent" do manifest = base(%{ ui_components: [ %{ - atom: :pad, - ios: %{view_module: "MobPad_View", swift_struct: "MobPadView"}, - android: %{composable: "MobPadComposable"} + atom: :chart, + ios: %{view_module: "Shared_Key"}, + android: %{factory: "io.mob.chart.MobChart"} } ] }) - %{registrations: [], placeholders: [ph], errors: []} = - AndroidBootstrap.classify([{"/a", manifest}]) - - assert ph == %{key: "MobPad_View", plugin: :p} + %{registrations: [reg]} = AndroidBootstrap.classify([{"/a", manifest}]) + assert reg.key == "Shared_Key" end - test "android backing without any registry key is a build error" do + test "opted-in factory without a registry key is a build error" do manifest = - base(%{ui_components: [%{atom: :pad, android: %{composable: "MobPad"}}]}) + base(%{ui_components: [%{atom: :pad, android: %{factory: "io.mob.MobPad"}}]}) - %{registrations: [], placeholders: [], errors: [err]} = - AndroidBootstrap.classify([{"/a", manifest}]) + %{registrations: [], errors: [err]} = AndroidBootstrap.classify([{"/a", manifest}]) assert err =~ ":pad" assert err =~ "no registry key" - assert err =~ "android.view_module" end - test "android backing without a composable is a build error" do + test "bare opted-in factory without a bridge_class is a build error" do manifest = - base(%{ui_components: [%{atom: :pad, ios: %{view_module: "K"}, android: %{}}]}) + base(%{ + ui_components: [ + %{atom: :pad, android: %{composable: "MobPad_View", factory: "MobPad"}} + ] + }) + + %{registrations: [], errors: [err]} = AndroidBootstrap.classify([{"/a", manifest}]) - %{errors: [err]} = AndroidBootstrap.classify([{"/a", manifest}]) - assert err =~ "no :composable" + assert err =~ "MobPad" + assert err =~ "fully qualified" end - test "iOS-only components and tier-0 (nil-manifest) plugins contribute nothing" do + test "present but invalid factory values are build errors" do + for factory <- [:Bad, nil, 42] do + manifest = + base(%{ + ui_components: [ + %{atom: :pad, android: %{composable: "MobPad_View", factory: factory}} + ] + }) + + %{registrations: [], errors: [err]} = + AndroidBootstrap.classify([{"/a", manifest}]) + + assert err =~ "android.factory" + assert err =~ inspect(factory) + end + end + + test "iOS-only components and tier-0 plugins contribute nothing" do plugins = [ {"/zero", nil}, {"/ios_only", base(%{ui_components: [%{atom: :x, ios: %{view_module: "X", swift_struct: "XV"}}]})} ] - assert AndroidBootstrap.classify(plugins) == - %{registrations: [], placeholders: [], errors: []} + assert AndroidBootstrap.classify(plugins) == %{registrations: [], errors: []} end - test "preserves order across plugins (activation order, then declaration order)" do + test "preserves activation and declaration order" do plugins = [ {"/a", base(%{ name: :a, ui_components: [ - %{atom: :a1, android: %{view_module: "A1", composable: "x.A1"}}, - %{atom: :a2, android: %{view_module: "A2", composable: "x.A2"}} + %{atom: :a1, android: %{composable: "A1", factory: "x.A1"}}, + %{atom: :a2, android: %{composable: "A2", factory: "x.A2"}} ] })}, {"/b", base(%{ name: :b, - ui_components: [%{atom: :b1, android: %{view_module: "B1", composable: "x.B1"}}] + ui_components: [%{atom: :b1, android: %{composable: "B1", factory: "x.B1"}}] })} ] @@ -144,46 +181,21 @@ defmodule MobDev.Plugin.AndroidBootstrapTest do end describe "ui_source/2" do - test "nothing to register yields nil (bootstrap stays byte-identical)" do - assert AndroidBootstrap.ui_source( - %{registrations: [], placeholders: [], errors: []}, - "com.example.app" - ) == nil + test "nothing to register yields nil" do + assert AndroidBootstrap.ui_source(%{registrations: [], errors: []}, "com.example.app") == + nil end - test "emits a fully-qualified register call per registration" do - classified = AndroidBootstrap.classify([{"/a", scene3d_manifest()}]) + test "forwards props and the native event sender to an opted-in factory" do + classified = AndroidBootstrap.classify([{"/a", auto_registered_manifest()}]) %{call: call, body: body} = AndroidBootstrap.ui_source(classified, "com.example.app") assert call == "registerUiComponents()" - assert body =~ "private fun registerUiComponents()" assert body =~ - ~s|com.example.app.MobNativeViewRegistry.register("Mob_Scene3d_Viewport") { props, _ ->| - - assert body =~ "io.mob.scene3d.MobScene3dViewport(props)" - # No placeholders declared — the loud-placeholder composable is not emitted. - refute body =~ "MissingUiComponent" - end + ~s|com.example.app.MobNativeViewRegistry.register("Mob_Scene3d_Viewport") { props, send ->| - test "emits the loud placeholder for unresolvable declared components" do - classified = %{ - registrations: [], - placeholders: [%{key: "MobPad_View", plugin: :pad_plugin}], - errors: [] - } - - %{body: body} = AndroidBootstrap.ui_source(classified, "com.example.app") - - assert body =~ - ~s|com.example.app.MobNativeViewRegistry.register("MobPad_View") { _, _ ->| - - assert body =~ ~s|MissingUiComponent("MobPad_View", "pad_plugin")| - assert body =~ "@androidx.compose.runtime.Composable" - assert body =~ "private fun MissingUiComponent(key: String, plugin: String)" - assert body =~ "android.util.Log.e" - assert body =~ "androidx.compose.material3.Text" - assert body =~ "androidx.compose.ui.graphics.Color.Red" + assert body =~ "io.mob.scene3d.MobScene3dViewport(props, send)" end end end diff --git a/test/mob_dev/plugin/validator_test.exs b/test/mob_dev/plugin/validator_test.exs index 51eee8e..e295974 100644 --- a/test/mob_dev/plugin/validator_test.exs +++ b/test/mob_dev/plugin/validator_test.exs @@ -247,15 +247,15 @@ defmodule MobDev.Plugin.ValidatorTest do assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") end - test "accepts a bare or fully-qualified android.composable", %{dir: dir} do - for composable <- ["MobScene3dViewport", "io.mob.scene3d.MobScene3dViewport"] do + test "accepts a bare or fully-qualified android.factory", %{dir: dir} do + for factory <- ["MobScene3dViewport", "io.mob.scene3d.MobScene3dViewport"] do m = Map.put(@base, :ui_components, [ %{ tag: "Scene3d", atom: :scene3d, ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, - android: %{composable: composable} + android: %{composable: "Mob_Scene3d_Viewport", factory: factory} } ]) @@ -263,7 +263,7 @@ defmodule MobDev.Plugin.ValidatorTest do end end - test "rejects an android.composable the Kotlin codegen cannot paste", %{dir: dir} do + test "rejects an android.factory the Kotlin codegen cannot paste", %{dir: dir} do for bad <- ["Mob-Bad-View", "io.mob.", "1Bad", MobScene3dViewport] do m = Map.put(@base, :ui_components, [ @@ -271,14 +271,14 @@ defmodule MobDev.Plugin.ValidatorTest do tag: "Scene3d", atom: :scene3d, ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, - android: %{composable: bad} + android: %{composable: "Mob_Scene3d_Viewport", factory: bad} } ]) assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") - assert Enum.any?(errs, &(&1 =~ "android.composable")), - "expected #{inspect(bad)} to be rejected as a composable" + assert Enum.any?(errs, &(&1 =~ "android.factory")), + "expected #{inspect(bad)} to be rejected as a factory" end end end @@ -611,6 +611,21 @@ defmodule MobDev.Plugin.ValidatorTest do assert Enum.any?(errs, &(&1 =~ "component atom")) end + test "detects duplicate effective Android registry keys across fallback fields" do + a = + Map.put(@base, :ui_components, [ + %{atom: :chart, android: %{composable: "Shared_Key", factory: "x.Chart"}} + ]) + + b = + Map.put(@base, :ui_components, [ + %{atom: :gauge, android: %{view_module: "Shared_Key", factory: "x.Gauge"}} + ]) + + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "Android native view key")) + end + test "detects a duplicate screen route across plugins" do a = Map.put(@base, :screens, [%{module: A, default_route: "/x"}]) b = Map.put(@base, :screens, [%{module: B, default_route: "/x"}])