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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).

## [Unreleased]

### Added
- **Plugins can contribute AndroidManifest `<application>` components and `res/`
files.** Two new optional `android:` manifest keys —
`manifest_application_snippets` (XML fragments spliced into the app's
`<application>` block, idempotent per `android:name`) and `res_files`
(plugin-relative paths copied into the app `res/` tree at their derived
`res/<type>/<file>` destination). This closes the gap that forced plugins
needing a `<service>`/`<receiver>`/`<provider>` + resource (e.g. `mob_nfc`'s
HCE `HostApduService` + `apduservice.xml`) to make it a manual
`host_requirement`. New `MobDev.Plugin.Merge.android_manifest_snippets/1` +
`android_res_files/1` gatherers (classified in the cross-plugin conflict
surface — duplicate component names / res destinations are build errors),
manifest-schema validation, and `NativeBuild` splice + copy (ledger-pruned
like `bridge_kt`). (MOB-39)

### Fixed
- **`mix mob.new_plugin` no longer scaffolds plugins pinned to the abandoned
`mob ~> 0.6`.** `MobDev.Plugin.Scaffold` hard-coded `{:mob, "~> 0.6"}` in the
Expand Down
185 changes: 176 additions & 9 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ defmodule MobDev.NativeBuild do
:ok <- apply_plugin_android_manifest!(),
:ok <- apply_plugin_gradle_deps!(),
:ok <- apply_plugin_android_kotlin!(),
:ok <- apply_plugin_android_res!(),
:ok <- apply_fonts_to_android!(),
:ok <- gradle_assemble(),
:ok <- adb_install_all(apk, bundle_id, device_id),
Expand Down Expand Up @@ -4470,19 +4471,25 @@ defmodule MobDev.NativeBuild do
# Layer 2.
MobDev.Plugin.Validator.raise_on_capability_drift!(activated)

permissions = MobDev.Plugin.Merge.android_permissions(activated)
snippets = for s <- MobDev.Plugin.Merge.android_manifest_snippets(activated), do: s.snippet

case File.read(@android_manifest_path) do
{:error, :enoent} ->
if MobDev.Plugin.Merge.android_permissions(activated) != [] do
if permissions != [] or snippets != [] do
IO.puts(
" [plugin android] #{@android_manifest_path} not found — skipping plugin permissions."
" [plugin android] #{@android_manifest_path} not found — skipping plugin " <>
"permissions + manifest components."
)
end

:ok

{:ok, content} ->
permissions = MobDev.Plugin.Merge.android_permissions(activated)
patched = merge_android_permissions(content, permissions)
patched =
content
|> merge_android_permissions(permissions)
|> merge_android_manifest_components(snippets)

if patched != content, do: File.write!(@android_manifest_path, patched)

Expand Down Expand Up @@ -4534,16 +4541,21 @@ defmodule MobDev.NativeBuild do
# (previous − current), then persists `current`. Per-scope and only called
# when that concern's merge runs, so an iOS-only build never prunes Android
# artifacts. Returns the pruned paths (for tests).
# The relative paths a prior build recorded for a plugin-artifact `scope`
# (empty when none). Shared by the prune and the res host-clobber guard.
defp read_plugin_artifact_ledger(scope) do
case File.read(Path.join(@plugin_artifact_ledger_dir, to_string(scope))) do
{:ok, body} -> String.split(body, "\n", trim: true)
_ -> []
end
end

@spec __prune_plugin_artifacts__(atom(), [Path.t()]) :: [Path.t()]
def __prune_plugin_artifacts__(scope, current) do
ledger = Path.join(@plugin_artifact_ledger_dir, to_string(scope))
current = current |> Enum.map(&Path.relative_to_cwd/1) |> Enum.uniq()

previous =
case File.read(ledger) do
{:ok, body} -> String.split(body, "\n", trim: true)
_ -> []
end
previous = read_plugin_artifact_ledger(scope)

pruned =
for stale <- previous -- current, File.exists?(stale) do
Expand Down Expand Up @@ -4814,6 +4826,110 @@ defmodule MobDev.NativeBuild do
:ok
end

@android_res_root "android/app/src/main"

# Copies each activated plugin's `android.res_files` into the app's `res/`
# tree (at its declared `res/<type>/<file>` destination), so a manifest
# component's `@xml/…` reference resolves at build time. Ledger-pruned like
# bridge_kt (a res file left by a since-removed plugin is deleted). Raises on
# two plugins targeting the same destination with different sources — the
# cross-plugin validator catches this at activation, this is the build-time
# backstop. No-op (with a notice) when the res root is missing.
#
# Two safety guards (the manifest validator enforces the first at activation
# too; these are the build-time backstop, since `activated/0` can feed
# unvalidated manifests):
# * containment — the resolved destination must stay under the app `res/`
# dir, so a `..`-bearing path can't make `File.cp!` write plugin bytes
# anywhere on the build host (path traversal).
# * no host clobber — refuse to overwrite a file this build didn't write on
# a previous run (tracked in the ledger); otherwise a plugin could replace
# a host-owned resource (e.g. res/values/styles.xml) and, worse, the
# ledger prune would later delete the host's file on plugin removal.
defp apply_plugin_android_res! do
res_files = MobDev.Plugin.Merge.android_res_files(MobDev.Plugin.activated())

cond do
res_files == [] ->
:ok

not File.dir?(@android_res_root) ->
IO.puts(" [plugin android] #{@android_res_root} not found — skipping plugin res files.")
:ok

true ->
raise_on_res_dest_collision!(res_files)
prior = read_plugin_artifact_ledger(:android_res)

written =
for %{src: src, dest: dest} <- res_files do
target = safe_res_target!(dest)
rel = Path.relative_to_cwd(target)

if File.exists?(target) and rel not in prior do
Mix.raise(
"plugin res file #{dest} would overwrite host-owned #{rel} — rename it in the plugin"
)
end

File.mkdir_p!(Path.dirname(target))
File.cp!(src, target)
IO.puts(" ✓ android res → #{rel}")
target
end

__prune_plugin_artifacts__(:android_res, written)
:ok
end
end

# Resolve a plugin res destination to a copy target, raising if it escapes the
# app `res/` dir. The hard security boundary behind the manifest validator's
# `..` rejection.
defp safe_res_target!(dest) do
case __res_target__(@android_res_root, dest) do
{:ok, target} ->
target

{:error, :escapes_res_dir} ->
Mix.raise(
"plugin res file destination escapes the app res/ dir: #{dest} " <>
"(path traversal — declared res_files must not contain \"..\")"
)
end
end

@doc false
# Pure: {:ok, copy_target} when `dest` (joined onto `root`) stays inside
# `root/res`, else {:error, :escapes_res_dir}. `..` and absolute escapes are
# normalised by Path.expand before the containment check.
@spec __res_target__(String.t(), String.t()) :: {:ok, String.t()} | {:error, :escapes_res_dir}
def __res_target__(root, dest) do
res_dir = Path.expand(Path.join(root, "res"))
target_abs = Path.expand(Path.join(root, dest))

if target_abs == res_dir or String.starts_with?(target_abs, res_dir <> "/"),
do: {:ok, Path.join(root, dest)},
else: {:error, :escapes_res_dir}
end

defp raise_on_res_dest_collision!(res_files) do
res_files
|> Enum.group_by(& &1.dest, & &1.src)
|> Enum.each(fn {dest, srcs} ->
case Enum.uniq(srcs) do
[_single] ->
:ok

many ->
Mix.raise(
"Android res collision: multiple plugins target #{dest}:\n " <>
Enum.join(many, "\n ") <> "\nRename one so plugin res files don't clash."
)
end
end)
end

# 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
Expand Down Expand Up @@ -5050,6 +5166,57 @@ defmodule MobDev.NativeBuild do
@spec __merge_gradle_deps__(String.t(), [String.t()]) :: String.t()
def __merge_gradle_deps__(content, deps), do: merge_gradle_deps(content, deps)

@doc false
@spec __merge_android_manifest_components__(String.t(), [String.t()]) :: String.t()
def __merge_android_manifest_components__(manifest, snippets),
do: merge_android_manifest_components(manifest, snippets)

# Pure transform: splice each plugin `<application>` snippet (a <service>,
# <receiver>, …) in just before `</application>`. Idempotent per component:
# skips any snippet whose `android:name` (or, lacking one, whose trimmed body)
# is already present, so re-runs and hand-added copies don't duplicate. Each
# snippet's own indentation is preserved and shifted 8 spaces to sit inside
# <application>. No `</application>` → returns the manifest untouched rather
# than risk corrupting it.
defp merge_android_manifest_components(manifest, []), do: manifest

defp merge_android_manifest_components(manifest, snippets) when is_binary(manifest) do
missing = Enum.reject(snippets, &manifest_component_present?(manifest, &1))

case missing do
[] ->
manifest

_ ->
block = Enum.map_join(missing, "\n\n", &indent_manifest_snippet/1)

if String.contains?(manifest, "</application>") do
String.replace(manifest, "</application>", "#{block}\n </application>",
global: false
)
else
manifest
end
end
end

defp manifest_component_present?(manifest, snippet) do
case Regex.run(~r/android:name="([^"]+)"/, snippet) do
[_, name] -> String.contains?(manifest, ~s(android:name="#{name}"))
_ -> String.contains?(manifest, String.trim(snippet))
end
end

defp indent_manifest_snippet(snippet) do
snippet
|> String.trim("\n")
|> String.split("\n")
|> Enum.map_join("\n", fn
"" -> ""
line -> " " <> line
end)
end

# Pure transform: insert new `<uses-permission>` tags for each permission not
# already declared. Insertion point is right after the last existing
# `<uses-permission ...>` line; failing that (no permissions declared yet),
Expand Down
67 changes: 67 additions & 0 deletions lib/mob_dev/plugin/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ defmodule MobDev.Plugin.Manifest do
|> check_mob_version(manifest)
|> check_spec_version(manifest)
|> check_permissions(manifest)
|> check_android_manifest_snippets(manifest)
|> check_android_res_files(manifest)
|> check_nifs(manifest)
|> check_screens(manifest)
|> check_screens_generator(manifest)
Expand Down Expand Up @@ -172,6 +174,71 @@ defmodule MobDev.Plugin.Manifest do
defp check_permission_entry(errors, other, i),
do: ["permissions entry ##{i} must be a map, got: #{inspect(other)}" | errors]

# `android.manifest_application_snippets` (optional) is a list of XML strings
# spliced into the app manifest's `<application>` block (a `<service>`,
# `<receiver>`, …). Each must be a non-empty string; content is the plugin
# author's responsibility (the native build inserts verbatim).
defp check_android_manifest_snippets(errors, manifest) do
case get_in(manifest, [:android, :manifest_application_snippets]) do
nil ->
errors

list when is_list(list) ->
if Enum.all?(list, &(is_binary(&1) and &1 != "")),
do: errors,
else: [
"android.manifest_application_snippets must be a list of non-empty XML strings"
| errors
]

other ->
[
"android.manifest_application_snippets must be a list of XML strings, got: #{inspect(other)}"
| errors
]
end
end

# `android.res_files` (optional) is a list of plugin-relative paths copied into
# the app's `res/` tree. Each must be a non-empty string containing a `res`
# path segment (the build derives the `res/<type>/<file>` destination from it)
# and must NOT contain a `..` segment — otherwise the derived destination could
# escape the app `res/` dir and `File.cp!` would write plugin bytes anywhere on
# the build host (path traversal). The native build enforces containment again
# at copy time as defense in depth.
defp check_android_res_files(errors, manifest) do
case get_in(manifest, [:android, :res_files]) do
nil ->
errors

list when is_list(list) ->
cond do
not Enum.all?(list, &(is_binary(&1) and &1 != "")) ->
["android.res_files must be a list of non-empty path strings" | errors]

Enum.any?(list, &(".." in Path.split(&1))) ->
[
"android.res_files paths must not contain a \"..\" segment " <>
"(path traversal — the copy destination must stay under the app res/ dir)"
| errors
]

not Enum.all?(list, &("res" in Path.split(&1))) ->
[
"android.res_files paths must contain a \"res\" segment (e.g. " <>
"priv/native/android/res/xml/foo.xml) so the build can place them"
| errors
]

true ->
errors
end

other ->
["android.res_files must be a list of path strings, got: #{inspect(other)}" | errors]
end
end

# `:nifs` is optional. When present, each entry's optional `:platform` (used by
# cross-platform plugins that ship a separate iOS + Android source for the same
# module) must be `:ios` or `:android`. For single-source C/ObjC/zig NIFs the
Expand Down
52 changes: 52 additions & 0 deletions lib/mob_dev/plugin/merge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,58 @@ defmodule MobDev.Plugin.Merge do
@spec gradle_deps([plugin()]) :: [String.t()]
def gradle_deps(plugins), do: collect_uniq(plugins, [:android, :gradle_deps])

@doc """
AndroidManifest `<application>` snippets each plugin contributes (a `<service>`,
`<receiver>`, `<provider>`, …), tagged with `:plugin`. `native_build` splices
each into the app manifest's `<application>` block (idempotent on the
component's `android:name`). Ships the manifest half of a component whose class
rides in the plugin's `bridge_kt`; see also `android_res_files/1` for the
`res/` half (e.g. an `apduservice.xml`).
"""
@spec android_manifest_snippets([plugin()]) :: [%{plugin: atom(), snippet: String.t()}]
def android_manifest_snippets(plugins) do
for {_dir, manifest} <- with_manifests(plugins),
snippet <- List.wrap(get_in(manifest, [:android, :manifest_application_snippets])),
is_binary(snippet),
do: %{plugin: manifest[:name], snippet: snippet}
end

@doc """
Android `res/` files each plugin contributes, resolved to `{plugin, src, dest}`.
`src` is absolute (against the plugin dir); `dest` is the path under
`android/app/src/main/` derived from the declared path's last `res/` segment
(`priv/native/android/res/xml/foo.xml` → `res/xml/foo.xml`). `native_build`
copies each into the app res tree. Pairs with `android_manifest_snippets/1`
(a `<meta-data android:resource="@xml/foo"/>` needs its `res/xml/foo.xml`).
"""
@spec android_res_files([plugin()]) :: [%{plugin: atom(), src: String.t(), dest: String.t()}]
def android_res_files(plugins) do
for {dir, manifest} <- with_manifests(plugins),
rel <- List.wrap(get_in(manifest, [:android, :res_files])),
is_binary(rel) do
%{plugin: manifest[:name], src: Path.join(dir, rel), dest: res_dest(rel)}
end
end

# Android res destination for a plugin-relative path: everything from the last
# `res` path segment onward, so it lands correctly typed under the app's
# `res/` tree. Falls back to `res/<basename>` when no `res` segment is present
# (the manifest validator rejects that case up front).
defp res_dest(rel) do
parts = Path.split(rel)

case last_index(parts, "res") do
nil -> Path.join("res", Path.basename(rel))
idx -> Path.join(Enum.drop(parts, idx))
end
end

defp last_index(list, value) do
list
|> Enum.with_index()
|> Enum.reduce(nil, fn {v, i}, acc -> if v == value, do: i, else: acc end)
end

@doc "Unique iOS framework names across plugins."
@spec ios_frameworks([plugin()]) :: [String.t()]
def ios_frameworks(plugins), do: collect_uniq(plugins, [:ios, :frameworks])
Expand Down
Loading
Loading