Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions decisions/2026-08-30-android-ui-components-bootstrap.md
Original file line number Diff line number Diff line change
@@ -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.
143 changes: 115 additions & 28 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -5234,7 +5321,7 @@ defmodule MobDev.NativeBuild do
if (perms != null) return perms
}
return null
}#{helpers}
}#{helpers}#{ui_body}
}
"""
end
Expand Down
Loading
Loading