diff --git a/CHANGELOG.md b/CHANGELOG.md index c0478d3..6357724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev). --- +## [Unreleased] + +### Fixed +- **Android hosts now register plugin `ui_components` composables + automatically** (mob_scene3d-q03). The manifest's + `ui_components.android.composable` was data nobody consumed: every host had + to hand-register the Compose factory in `MainActivity.onCreate`, and a host + that forgot rendered the component as *nothing* — + `MobNativeViewRegistry.render` returns silently on an unknown key. The + generated `MobPluginBootstrap.registerAll(this)` (already called by every + generated/adopted MainActivity) now registers each activated plugin's + composable with the app's `MobNativeViewRegistry`, mirroring iOS's + `mob_register_plugins()` bootstrap (`MobDev.Plugin.AndroidBootstrap`). The + registry key is `android.view_module`, falling back to `ios.view_module`; + a bare `composable` is qualified with the `bridge_class` package. Silent + blanks are gone: a typo'd composable fails the Gradle Kotlin compile, a + malformed declaration (no key / no composable) fails the mob_dev build with + the manifest error, and a declared-but-unresolvable composable (the + hand-copied tier-2 workflow) registers a loud red "Missing native + component" placeholder + `Log.e` that the host's own later registration + overwrites. The validator also rejects an `android.composable` that isn't a + Kotlin identifier or dotted path, at validate time instead of Gradle time. + ## [0.6.30] - 2026-08-30 ### Fixed diff --git a/decisions/2026-08-30-android-ui-components-bootstrap.md b/decisions/2026-08-30-android-ui-components-bootstrap.md new file mode 100644 index 0000000..78de383 --- /dev/null +++ b/decisions/2026-08-30-android-ui-components-bootstrap.md @@ -0,0 +1,63 @@ +# Android ui_components registration rides the generated MobPluginBootstrap + +- Date: 2026-08-30 +- Status: accepted + +## Context + +The plugin manifest's `ui_components.android.composable` was data nobody +consumed. iOS has a working story: `MobDev.Plugin.IOSBootstrap` code-generates +`mob_register_plugins()` from `ui_components.ios`, and the host AppDelegate +calls it before `mob_init_ui()`. Android had no analog — every host +hand-registered each plugin composable in `MainActivity.onCreate`, and a host +that forgot rendered the component as *nothing*: `MobNativeViewRegistry.render` +returns silently on an unknown key (mob_scene3d-q03; chopaat carries the +workaround under that bead id). The plugin bridge's own `register()` could not +do it because `MobNativeViewRegistry` lives in the *app* package (MobBridge.kt) +and a plugin's `io.mob.*` code can't name that package at authoring time. + +## Decision + +Registration is generated into the existing `io.mob.plugin.MobPluginBootstrap` +(`MobDev.Plugin.AndroidBootstrap` + `NativeBuild.__bootstrap_kotlin__/2`), +whose `registerAll(this)` every generated/adopted MainActivity already calls +before `setContent` — no template or host edit needed, and the app-package +problem dissolves because codegen *discovers* the app package (the package of +the file defining `object MobNativeViewRegistry`) and emits fully-qualified +references. + +Non-obvious calls: + +- **Registry key = `android.view_module`, falling back to `ios.view_module`.** + The key is platform-independent (the Elixir module name, dots → + underscores), and existing manifests only carry it on the iOS side. The + android-side override exists so an Android-only plugin needs no `:ios` map. +- **A bare `composable` is qualified with the `bridge_class` package** — the + composable ships in the plugin's `bridge_kt`, which declares that package + (mob_scene3d: `io.mob.scene3d.MobScene3dViewport`). A fully-qualified + `composable` is used as-is. +- **Failure is loud at the earliest layer that can see it.** Malformed + declaration (no key / no composable) → `Mix.raise` at build, next to the + manifest. Typo'd composable → Gradle Kotlin compile error (the generated + call references the symbol). Declared-but-unresolvable (bare composable, no + bridge — the hand-copied tier-2 workflow) → a generated placeholder factory + that renders a red "Missing native component" tile and `Log.e`s; the host's + own registration *after* `registerAll` overwrites it, so the documented + tier-2 flow keeps working while a forgotten registration can no longer be + silent. +- **Hosts without the registry (LiveView wrappers, pre-registry templates) + skip UI codegen with a printed warning** — they have no native-view render + path at all, so generated references would only break their compile. + +## Consequences + +- The chopaat MainActivity workaround (and the s3d_spike hand registration) + become removable: activating a ui_components-bearing plugin is enough. +- The generated bootstrap now contains Compose lambdas when (and only when) + ui components exist, so plugin-less builds stay byte-identical; UI-only + bootstraps omit the bridge handOff/permission helpers to avoid unused + private functions. +- The tier-2 scaffold still declares a bare composable with no bridge; its + hosts now see the loud placeholder until they register by hand. Follow-up: + scaffold could ship the composable in a bridge_kt so tier-2 gets + auto-registration too. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index cdb4808..94bbaef 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -5058,8 +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()` and then - # hands the Activity to any bridge implementing `MobActivityAware`. + # `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 + # 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`. # The `MobActivityAware` contract is written alongside the bootstrap, and # both are always written (empty registerAll body when no plugin declares a @@ -5103,7 +5106,10 @@ defmodule MobDev.NativeBuild do write_generated_kotlin!( @plugin_bootstrap_path, - __bootstrap_kotlin__(MobDev.Plugin.Merge.bridge_classes(activated)) + __bootstrap_kotlin__( + MobDev.Plugin.Merge.bridge_classes(activated), + android_ui_source!(activated) + ) ) :ok @@ -5126,6 +5132,67 @@ 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. + # 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. + defp android_ui_source!(activated) do + classified = MobDev.Plugin.AndroidBootstrap.classify(activated) + + if classified.errors != [] do + Mix.raise( + "Android ui_components cannot be registered:\n " <> + Enum.join(classified.errors, "\n ") + ) + end + + case {classified.registrations ++ classified.placeholders, + __android_app_package__(@android_java_root)} do + {[], _} -> + nil + + {_some, nil} -> + IO.puts( + " [plugin android] activated plugins declare ui_components but no " <> + "MobNativeViewRegistry was found under #{@android_java_root} " <> + "(MobBridge.kt) — skipping Compose factory registration. Native " <> + "view components will not render in this host." + ) + + nil + + {_some, app_package} -> + MobDev.Plugin.AndroidBootstrap.ui_source(classified, app_package) + end + end + + # The host app's Kotlin package — the package of the source file that + # defines `object MobNativeViewRegistry` (MobBridge.kt in generated and + # adopted hosts). The registry lives in the app package, which io.mob.plugin + # code can only reference fully qualified; codegen discovers it here. Nil + # when no defining file exists under the java root. + @doc false + @spec __android_app_package__(String.t()) :: String.t() | nil + def __android_app_package__(java_root) do + java_root + |> Path.join("**/*.kt") + |> Path.wildcard() + |> Enum.find_value(fn path -> + case File.read(path) do + {:ok, content} -> + if String.contains?(content, "object MobNativeViewRegistry"), + do: __parse_kotlin_package__(content) + + _ -> + nil + end + end) + end + @doc false @spec __bridge_kt_dest__(String.t(), String.t(), String.t()) :: String.t() def __bridge_kt_dest__(java_root, package, basename) do @@ -5176,44 +5243,64 @@ defmodule MobDev.NativeBuild do # `as?` runtime check is valid for every bridge type — a direct # `(SomeFinalObject as? MobActivityAware)` would draw a "cast can never # succeed" warning for bridges that don't opt in. + # + # `ui` is the ui_components half from MobDev.Plugin.AndroidBootstrap + # (`%{call:, body:}` or nil): registerAll additionally runs `ui.call` so the + # plugins' Compose factories are registered before MainActivity's setContent + # renders anything, and `ui.body` splices the generated member functions + # into the object. @doc false - @spec __bootstrap_kotlin__([String.t()]) :: String.t() - def __bootstrap_kotlin__(bridge_classes) do - calls = + @spec __bootstrap_kotlin__([String.t()], %{call: String.t(), body: String.t()} | nil) :: + String.t() + def __bootstrap_kotlin__(bridge_classes, ui \\ nil) do + bridge_calls = bridge_classes |> Enum.map(fn cls -> " #{cls}.register()\n handOff(#{cls}, activity)\n collectPermissionProvider(#{cls})" end) |> Enum.join("\n") - {body, helpers} = - if calls == "" do - {"", ""} + ui_call = if ui, do: " #{ui.call}", else: "" + ui_body = if ui, do: ui.body, else: "" + + calls = + [bridge_calls, ui_call] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + + body = if calls == "", do: "", else: "\n" <> calls <> "\n " + + # The handOff/collectPermissionProvider helpers exist for bridge classes + # only — a UI-only bootstrap must not emit them unused. + helpers = + if bridge_calls == "" do + "" else - {"\n" <> calls <> "\n ", - "\n\n // Hands the Activity to a bridge that opts in via" <> - " MobActivityAware.\n" <> - " private fun handOff(bridge: Any, activity: Activity) {\n" <> - " (bridge as? MobActivityAware)?.setActivity(activity)\n" <> - " }\n\n" <> - " // Records a bridge that opts in via MobPermissionProvider so" <> - " core\n" <> - " // MobBridge.request_permission can fall through to it for a" <> - " capability\n" <> - " // core no longer knows about.\n" <> - " private fun collectPermissionProvider(bridge: Any) {\n" <> - " (bridge as? MobPermissionProvider)?.let {\n" <> - " if (!permissionProviders.contains(it)) permissionProviders.add(it)\n" <> - " }\n" <> - " }"} + "\n\n // Hands the Activity to a bridge that opts in via" <> + " MobActivityAware.\n" <> + " private fun handOff(bridge: Any, activity: Activity) {\n" <> + " (bridge as? MobActivityAware)?.setActivity(activity)\n" <> + " }\n\n" <> + " // Records a bridge that opts in via MobPermissionProvider so" <> + " core\n" <> + " // MobBridge.request_permission can fall through to it for a" <> + " capability\n" <> + " // core no longer knows about.\n" <> + " private fun collectPermissionProvider(bridge: Any) {\n" <> + " (bridge as? MobPermissionProvider)?.let {\n" <> + " if (!permissionProviders.contains(it)) permissionProviders.add(it)\n" <> + " }\n" <> + " }" end """ // 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; invoked from - // MainActivity.onCreate as registerAll(this). + // any bridge implementing MobPermissionProvider; also registers the plugins' + // ui_components Compose factories with the app's MobNativeViewRegistry. + // Invoked from MainActivity.onCreate as registerAll(this), before + // setContent renders anything. package io.mob.plugin import android.app.Activity @@ -5234,7 +5321,7 @@ defmodule MobDev.NativeBuild do if (perms != null) return perms } return null - }#{helpers} + }#{helpers}#{ui_body} } """ end diff --git a/lib/mob_dev/plugin/android_bootstrap.ex b/lib/mob_dev/plugin/android_bootstrap.ex new file mode 100644 index 0000000..d995d56 --- /dev/null +++ b/lib/mob_dev/plugin/android_bootstrap.ex @@ -0,0 +1,234 @@ +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. + """ + + alias MobDev.Plugin.Merge + + @type classified :: %{ + registrations: [%{key: String.t(), composable: String.t(), plugin: atom()}], + placeholders: [%{key: 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). + + Components without an `:android` map (iOS-only) contribute nothing here — + the validator's single-platform warning is what nags about those. + """ + @spec classify([Merge.plugin()]) :: classified() + def classify(plugins) do + buckets = + for {_dir, manifest} <- plugins, + is_map(manifest), + component <- Map.get(manifest, :ui_components, []), + is_map(component), + android = component[:android], + is_map(android) 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) + } + end + + defp classify_component(component, android, manifest) do + plugin = manifest[:name] + key = registry_key(component) + composable = android[:composable] + bridge_pkg = bridge_package(manifest) + + cond do + not is_binary(key) -> + {: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"} + + not is_binary(composable) -> + {: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"} + + String.contains?(composable, ".") -> + {:registration, %{key: key, composable: composable, plugin: plugin}} + + is_binary(bridge_pkg) -> + {:registration, %{key: key, composable: "#{bridge_pkg}.#{composable}", plugin: plugin}} + + true -> + {:placeholder, %{key: key, plugin: plugin}} + 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]) + end + end + + defp component_label(component) do + inspect(component[:atom] || component[:tag] || component) + end + + # 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 + Enum.join(parts, ".") + else + _ -> nil + end + 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. + """ + @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)) + + if lines == [] do + nil + else + %{call: "registerUiComponents()", body: ui_body(lines, classified.placeholders)} + end + end + + # Unused lambda params are `_` — Kotlin warns on named-but-unused ones. + defp registration_kotlin(%{key: key, composable: composable, 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}\") + } + """ + |> 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") + end +end diff --git a/lib/mob_dev/plugin/scaffold.ex b/lib/mob_dev/plugin/scaffold.ex index facf5de..e31a45b 100644 --- a/lib/mob_dev/plugin/scaffold.ex +++ b/lib/mob_dev/plugin/scaffold.ex @@ -506,11 +506,14 @@ defmodule MobDev.Plugin.Scaffold do """ // #{mod} — tier-2 plugin Compose factory. // - // Until the plugin merge engine wires plugin Kotlin into the build - // automatically, 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. + // 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. object #{mod}Plugin { fun register() { diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex index 5b102a0..63a241b 100644 --- a/lib/mob_dev/plugin/validator.ex +++ b/lib/mob_dev/plugin/validator.ex @@ -58,6 +58,7 @@ defmodule MobDev.Plugin.Validator do |> add_mob_version_error(manifest, installed_mob_version) |> add_nif_module_errors(manifest) |> add_swift_struct_errors(manifest) + |> add_composable_errors(manifest) |> add_swift_import_errors(manifest, plugin_dir) |> add_android_permission_errors(manifest, plugin_dir) |> add_warnings(manifest) @@ -421,6 +422,45 @@ 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. + @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 + errs = + for c <- Map.get(manifest, :ui_components, []), + is_map(c), + android = c[:android], + is_map(android), + Map.has_key?(android, :composable), + err = composable_error(android[:composable]), + do: err + + %{result | errors: result.errors ++ errs} + end + + defp add_composable_errors(result, _manifest), do: result + + defp composable_error(value) when is_binary(value) do + if Regex.match?(@kotlin_composable_pattern, value), + do: nil, + else: bad_composable_message(value) + end + + 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 " <> + "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)`" + end + defp add_swift_import_errors(result, manifest, plugin_dir) do %{result | errors: result.errors ++ validate_swift_imports(manifest, plugin_dir)} end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 8ed08cf..64ee921 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -460,6 +460,92 @@ defmodule MobDev.NativeBuildTest do refute src =~ "collectPermissionProvider" end + test "__bootstrap_kotlin__ splices the ui_components half into registerAll" do + ui = %{ + 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 }" + } + + 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. + 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 src =~ "private fun registerUiComponents()" + assert src =~ ~s|com.example.app.MobNativeViewRegistry.register("Mob_Scene3d_Viewport")| + assert src =~ "io.mob.scene3d.MobScene3dViewport(props)" + end + + test "__bootstrap_kotlin__ registers ui_components even with zero bridge classes, without bridge helpers" do + ui = %{ + call: "registerUiComponents()", + body: "\n\n private fun registerUiComponents() {\n }" + } + + src = NativeBuild.__bootstrap_kotlin__([], ui) + + assert src =~ "fun registerAll(activity: Activity) {\n registerUiComponents()\n }" + refute src =~ "handOff" + refute src =~ "collectPermissionProvider(io" + end + + test "__bootstrap_kotlin__ without a ui half matches the pre-ui output byte for byte" do + assert NativeBuild.__bootstrap_kotlin__(["io.mob.x.Bridge"]) == + NativeBuild.__bootstrap_kotlin__(["io.mob.x.Bridge"], nil) + + refute NativeBuild.__bootstrap_kotlin__(["io.mob.x.Bridge"]) =~ "registerUiComponents" + end + + test "__android_app_package__ finds the package of the file defining MobNativeViewRegistry" do + root = + Path.join( + System.tmp_dir!(), + "mob_app_pkg_#{System.unique_integer([:positive])}" + ) + + app_dir = Path.join(root, "com/genericjam/chopaat") + plugin_dir = Path.join(root, "io/mob/plugin") + File.mkdir_p!(app_dir) + File.mkdir_p!(plugin_dir) + + on_exit(fn -> File.rm_rf!(root) end) + + # Decoy without the registry — must not win. + File.write!( + Path.join(plugin_dir, "MobPluginBootstrap.kt"), + "package io.mob.plugin\n\nobject MobPluginBootstrap {}\n" + ) + + File.write!( + Path.join(app_dir, "MobBridge.kt"), + "package com.genericjam.chopaat\n\nobject MobNativeViewRegistry {}\n" + ) + + assert NativeBuild.__android_app_package__(root) == "com.genericjam.chopaat" + end + + test "__android_app_package__ is nil when no file defines the registry" do + root = + Path.join( + System.tmp_dir!(), + "mob_app_pkg_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf!(root) end) + + assert NativeBuild.__android_app_package__(root) == nil + end + test "__activity_aware_kotlin__ emits the stable MobActivityAware contract" do src = NativeBuild.__activity_aware_kotlin__() assert src =~ "package io.mob.plugin" diff --git a/test/mob_dev/plugin/android_bootstrap_test.exs b/test/mob_dev/plugin/android_bootstrap_test.exs new file mode 100644 index 0000000..ce520e5 --- /dev/null +++ b/test/mob_dev/plugin/android_bootstrap_test.exs @@ -0,0 +1,189 @@ +defmodule MobDev.Plugin.AndroidBootstrapTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.AndroidBootstrap + + 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 + base(%{ + name: :mob_scene3d, + ui_components: [ + %{ + tag: "Scene3d", + atom: :scene3d, + ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, + android: %{composable: "MobScene3dViewport"} + } + ], + android: %{bridge_class: "io.mob.scene3d.MobScene3dBridge"} + }) + 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()}]) + + assert reg == %{ + key: "Mob_Scene3d_Viewport", + composable: "io.mob.scene3d.MobScene3dViewport", + plugin: :mob_scene3d + } + end + + test "uses a fully-qualified composable as-is (no bridge_class needed)" do + manifest = + base(%{ + ui_components: [ + %{ + atom: :chart, + ios: %{view_module: "Mob_Chart"}, + android: %{composable: "io.mob.chart.MobChart"} + } + ] + }) + + %{registrations: [reg], placeholders: [], errors: []} = + AndroidBootstrap.classify([{"/a", manifest}]) + + assert reg.composable == "io.mob.chart.MobChart" + end + + test "android.view_module wins over ios.view_module as the registry key" do + manifest = + base(%{ + ui_components: [ + %{ + atom: :chart, + ios: %{view_module: "Ios_Key"}, + android: %{view_module: "Android_Key", composable: "io.mob.chart.MobChart"} + } + ] + }) + + %{registrations: [reg]} = AndroidBootstrap.classify([{"/a", manifest}]) + assert reg.key == "Android_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. + manifest = + base(%{ + ui_components: [ + %{ + atom: :pad, + ios: %{view_module: "MobPad_View", swift_struct: "MobPadView"}, + android: %{composable: "MobPadComposable"} + } + ] + }) + + %{registrations: [], placeholders: [ph], errors: []} = + AndroidBootstrap.classify([{"/a", manifest}]) + + assert ph == %{key: "MobPad_View", plugin: :p} + end + + test "android backing without any registry key is a build error" do + manifest = + base(%{ui_components: [%{atom: :pad, android: %{composable: "MobPad"}}]}) + + %{registrations: [], placeholders: [], 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 + manifest = + base(%{ui_components: [%{atom: :pad, ios: %{view_module: "K"}, android: %{}}]}) + + %{errors: [err]} = AndroidBootstrap.classify([{"/a", manifest}]) + assert err =~ "no :composable" + end + + test "iOS-only components and tier-0 (nil-manifest) 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: []} + end + + test "preserves order across plugins (activation order, then 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"}} + ] + })}, + {"/b", + base(%{ + name: :b, + ui_components: [%{atom: :b1, android: %{view_module: "B1", composable: "x.B1"}}] + })} + ] + + %{registrations: regs} = AndroidBootstrap.classify(plugins) + assert Enum.map(regs, & &1.key) == ["A1", "A2", "B1"] + end + 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 + end + + test "emits a fully-qualified register call per registration" do + classified = AndroidBootstrap.classify([{"/a", scene3d_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 + + 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" + end + end +end diff --git a/test/mob_dev/plugin/validator_test.exs b/test/mob_dev/plugin/validator_test.exs index 5c0d0b3..51eee8e 100644 --- a/test/mob_dev/plugin/validator_test.exs +++ b/test/mob_dev/plugin/validator_test.exs @@ -246,6 +246,41 @@ 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 + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Scene3d", + atom: :scene3d, + ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, + android: %{composable: composable} + } + ]) + + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + end + + test "rejects an android.composable the Kotlin codegen cannot paste", %{dir: dir} do + for bad <- ["Mob-Bad-View", "io.mob.", "1Bad", MobScene3dViewport] do + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Scene3d", + atom: :scene3d, + ios: %{view_module: "Mob_Scene3d_Viewport", swift_struct: "MobScene3dViewport"}, + android: %{composable: 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" + end + end end describe "validate_swift_imports/2" do