diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 73b7f06..3fc51db 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -172,6 +172,10 @@ defmodule MobDev.NativeBuild do IO.puts(" Compiling Android C objects via zig build (per-ABI)...") + # Copy plugin Kotlin sources into the project tree so Gradle + # compiles them alongside the generated MobBridge.kt. + copy_plugin_android_kotlin_sources() + # Cross-compile project Rust/Zig NIFs once per ABI. Each # invocation targets `aarch64-linux-android` or # `armv7-linux-androideabi` (Rust) / `arm-linux-androideabi` @@ -264,11 +268,14 @@ defmodule MobDev.NativeBuild do # but received a list". nif_args_no_root = Enum.reject(nif_args, &String.starts_with?(&1, "-Dproject_root=")) + plugin_sources = plugin_android_c_sources_arg() + args = base_args ++ nif_args_no_root ++ nxeigen_zig_args_android(nxeigen_archive) ++ - tflite_zig_args_android(tflite_build) + tflite_zig_args_android(tflite_build) ++ + if(plugin_sources != "", do: [plugin_sources], else: []) case System.cmd("zig", args, stderr_to_stdout: true, into: IO.stream()) do {_, 0} -> :ok @@ -4800,11 +4807,185 @@ defmodule MobDev.NativeBuild do :code.lib_dir(:elixir) |> to_string() |> Path.dirname() end + # ── Plugin native source auto-discovery ──────────────────────────────────── + # + # A "plugin" in this context is any Mix dependency that ships native + # bridge sources at: + # + # /priv/native/ios/*.swift — iOS Swift bridges + # /priv/native/android/jni/*.c — Android C sources (compiled + # into lib.so by zig) + # /priv/native/android/*.kt — Android Kotlin bridges + # (copied into the project + # tree; Gradle compiles them) + # + # mob_iap is the canonical example. New plugins should follow this layout. + + # Scans all Mix dependencies for plugin Swift sources and returns a + # list of absolute paths. Used by the iOS build to auto-wire plugin + # native bridges (e.g. MobIapBridge.swift) into the swiftc invocation. + defp scan_plugin_ios_swift_sources do + Mix.Project.deps_paths() + |> Enum.flat_map(fn {_name, dep_path} -> + swift_dir = Path.join(dep_path, "priv/native/ios") + + if File.dir?(swift_dir) do + Path.wildcard(Path.join(swift_dir, "*.swift")) + else + [] + end + end) + end + + # Scans all Mix dependencies for plugin Android C sources and returns + # a list of {name, path} tuples. name is the filename without extension, + # used as the .o basename in the zig build. + # + # Raises if two plugins ship a C file with the same basename — both + # would compile to the same .o and silently overwrite each other in + # the final .so. The fix is to rename one of them; we cannot do that + # automatically. + defp scan_plugin_android_c_sources do + Mix.Project.deps_paths() + |> Enum.flat_map(fn {dep_name, dep_path} -> + c_dir = Path.join(dep_path, "priv/native/android/jni") + + if File.dir?(c_dir) do + Path.wildcard(Path.join(c_dir, "*.c")) + |> Enum.map(fn path -> + name = path |> Path.basename() |> Path.rootname() + {name, path, dep_name} + end) + else + [] + end + end) + |> dedupe_plugin_c_sources!() + end + + @doc false + # Pure helper, public for testing. Takes a list of {name, path, dep} + # triples and returns {name, path} pairs after a collision check. + # Raises via Mix.raise/1 if any name appears more than once. + @spec dedupe_plugin_c_sources!([{String.t(), String.t(), atom()}]) :: [{String.t(), String.t()}] + def dedupe_plugin_c_sources!(sources) do + collisions = + sources + |> Enum.group_by(fn {name, _, _} -> name end) + |> Enum.filter(&match?({_, [_, _ | _]}, &1)) + + case collisions do + [] -> + Enum.map(sources, fn {name, path, _dep} -> {name, path} end) + + _ -> + details = + Enum.map_join(collisions, "\n", fn {name, entries} -> + owners = Enum.map_join(entries, ", ", fn {_, _, dep} -> to_string(dep) end) + " #{name}.c is provided by: #{owners}" + end) + + Mix.raise(""" + Duplicate plugin Android C source basenames — each would compile to + the same .o file and overwrite the other in lib.so: + + #{details} + + Rename one of the conflicting files in its source plugin. + """) + end + end + + # Builds the `-Dproject_plugin_sources=name1:path1,name2:path2` string + # for the Android zig build. Empty string when no plugin C sources found. + defp plugin_android_c_sources_arg do + case scan_plugin_android_c_sources() do + [] -> + "" + + sources -> + pairs = Enum.map(sources, fn {name, path} -> "#{name}:#{path}" end) + "-Dproject_plugin_sources=#{Enum.join(pairs, ",")}" + end + end + + # Copies plugin Kotlin files from deps' priv/native/android/ into the + # project's Java source tree so Gradle compiles them. Each .kt file is + # placed in the same package directory as the generated MobBridge. + # Returns :ok. + # + # Always overwrites the destination. Earlier we skipped existing files, + # but that meant a `mix deps.update mob_iap` couldn't refresh the + # bridge — the first-build copy persisted and Gradle kept compiling + # the stale version. Forking a plugin's bridge in-tree is not + # supported; users who need to customize should fork the plugin + # itself. + defp copy_plugin_android_kotlin_sources do + plugin_kt_files = + Mix.Project.deps_paths() + |> Enum.flat_map(fn {dep_name, dep_path} -> + kt_dir = Path.join(dep_path, "priv/native/android") + + if File.dir?(kt_dir) do + Path.wildcard(Path.join(kt_dir, "*.kt")) + |> Enum.map(fn src -> {dep_name, src} end) + else + [] + end + end) + + case plugin_kt_files do + [] -> + :ok + + files -> + java_pkg_dir = detect_android_java_pkg_dir() + + if java_pkg_dir do + Enum.each(files, fn {_dep_name, src} -> + dst = Path.join(java_pkg_dir, Path.basename(src)) + cp(src, dst) + IO.puts(" ✓ copied plugin Kotlin source: #{dst}") + end) + else + owners = files |> Enum.map_join(", ", fn {dep, _} -> to_string(dep) end) + + Mix.raise(""" + Could not determine Android Java package directory — expected to + read `package ` from android/app/src/main/java/MainActivity.kt, + but the file is missing or does not declare a package. + + Plugins waiting on Kotlin source copy: #{owners} + + If your project uses a non-standard layout, file an issue at + GenericJam/mob_dev with the path to your MainActivity. + """) + end + + :ok + end + end + + # Reads the package declaration from MainActivity.kt and converts it + # to a directory path under android/app/src/main/java. Returns nil if + # the file is missing or the package can't be parsed. + defp detect_android_java_pkg_dir do + with {:ok, content} <- File.read("android/app/src/main/java/MainActivity.kt"), + [_, pkg] <- Regex.run(~r/package\s+([\w.]+)/, content) do + Path.join(["android/app/src/main/java" | String.split(pkg, ".")]) + else + _ -> nil + end + end + defp project_swift_sources_arg(cfg) do - cfg - |> Keyword.get(:project_swift_sources, []) - |> normalize_project_swift_sources!() - |> Enum.join(",") + user_sources = + cfg + |> Keyword.get(:project_swift_sources, []) + |> normalize_project_swift_sources!() + + plugin_sources = scan_plugin_ios_swift_sources() + (user_sources ++ plugin_sources) |> Enum.join(",") end defp normalize_project_swift_sources!(nil), do: [] diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index fb4449c..996f1ec 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1061,4 +1061,62 @@ defmodule MobDev.NativeBuildTest do assert String.contains?(content, "iphoneos"), "patch should switch to iphoneos SDK" end end + + describe "dedupe_plugin_c_sources!/1" do + test "empty input returns empty list" do + assert NativeBuild.dedupe_plugin_c_sources!([]) == [] + end + + test "drops the dep name from triples to return {name, path} pairs" do + assert NativeBuild.dedupe_plugin_c_sources!([ + {"iap", "/deps/mob_iap/priv/native/android/jni/iap.c", :mob_iap} + ]) == [{"iap", "/deps/mob_iap/priv/native/android/jni/iap.c"}] + end + + test "preserves order and allows distinct basenames from the same dep" do + sources = [ + {"iap", "/deps/mob_iap/priv/native/android/jni/iap.c", :mob_iap}, + {"helper", "/deps/mob_iap/priv/native/android/jni/helper.c", :mob_iap} + ] + + assert NativeBuild.dedupe_plugin_c_sources!(sources) == [ + {"iap", "/deps/mob_iap/priv/native/android/jni/iap.c"}, + {"helper", "/deps/mob_iap/priv/native/android/jni/helper.c"} + ] + end + + test "raises with a helpful message when two plugins ship the same basename" do + sources = [ + {"iap", "/deps/mob_iap/priv/native/android/jni/iap.c", :mob_iap}, + {"iap", "/deps/other_iap/priv/native/android/jni/iap.c", :other_iap} + ] + + msg = + assert_raise Mix.Error, fn -> + NativeBuild.dedupe_plugin_c_sources!(sources) + end + + assert msg.message =~ "Duplicate plugin Android C source basenames" + assert msg.message =~ "iap.c is provided by:" + assert msg.message =~ "mob_iap" + assert msg.message =~ "other_iap" + end + + test "lists every collision when there are multiple" do + sources = [ + {"a", "/deps/p1/priv/native/android/jni/a.c", :p1}, + {"a", "/deps/p2/priv/native/android/jni/a.c", :p2}, + {"b", "/deps/p1/priv/native/android/jni/b.c", :p1}, + {"b", "/deps/p3/priv/native/android/jni/b.c", :p3} + ] + + msg = + assert_raise Mix.Error, fn -> + NativeBuild.dedupe_plugin_c_sources!(sources) + end + + assert msg.message =~ "a.c is provided by:" + assert msg.message =~ "b.c is provided by:" + end + end end