From 9726f3524e305c70fbb69f6fc5f88ab69cd7ca5d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 25 Aug 2026 15:10:55 -0600 Subject: [PATCH 1/2] MOB-98: mix mob.doctor detects the pre-fix component-event JNI mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mob_new's template fix (JNI owner mismatch — nativeDeliverComponentEvent declared on MobNativeViewRegistry instead of MobBridge) only repairs newly-generated projects. This repo doesn't auto-patch hand-editable native source in existing generated apps (see enable.ex's detect_stale_pythonx_templates for the established precedent — detect + warn, never blind-overwrite), so an already-generated app stays broken until a human ports the fix or regenerates. mix mob.doctor's Build section now flags the pre-fix declaration shape in an existing MobBridge.kt, pointing at the same fix mob_new's template already carries — surfaced by `mix mob.doctor` instead of an UnsatisfiedLinkError on first real interaction with a tier-2 native component. --- lib/mix/tasks/mob.doctor.ex | 53 +++++++++++++++++++++++++++++- test/mix/tasks/mob_doctor_test.exs | 26 +++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 4085623..791912b 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -573,13 +573,64 @@ defmodule Mix.Tasks.Mob.Doctor do check_deps_fetched(), check_compiled(), check_driver_tab(), - check_plugin_build_options() + check_plugin_build_options(), + check_component_event_jni() ]) else [] end end + # ── Component event JNI ownership (MOB-98) ──────────────────────────────── + # + # Apps generated before the mob_new fix declare `nativeDeliverComponentEvent` + # as an `external fun` on MobNativeViewRegistry, but the generated JNI export + # is `Java_..._MobBridge_nativeDeliverComponentEvent` — JNI resolves a native + # method by its declaring class, so the mismatch only surfaces as an + # UnsatisfiedLinkError the first time a real tier-2 native component fires an + # event. mob_new's template fix doesn't reach already-generated projects (see + # mob_dev/decisions/ for why this repo doesn't auto-patch hand-editable native + # source) — this check exists so it's caught by `mix mob.doctor` instead of a + # crash on first real interaction. + defp check_component_event_jni do + "android/app/src/main/java/**/MobBridge.kt" + |> Path.wildcard() + |> Enum.flat_map(fn path -> + case File.read(path) do + {:ok, content} -> + if __component_event_jni_mismatched__(content) do + [ + {:warn, "component event JNI (#{path})", + "declares nativeDeliverComponentEvent as an external fun on " <> + "MobNativeViewRegistry, but the generated JNI export is owned by " <> + "MobBridge — JNI resolves a native method by its declaring class, so " <> + "a real tier-2 native component event throws UnsatisfiedLinkError", + "Port the fix from a freshly generated app (mix mob.new) or " <> + "mob_new's MobBridge.kt.eex: move `external fun " <> + "nativeDeliverComponentEvent` onto MobBridge as `@JvmStatic external " <> + "fun`, and call it as `MobBridge.nativeDeliverComponentEvent(...)` " <> + "from MobNativeViewRegistry."} + ] + else + [] + end + + {:error, _} -> + [] + end + end) + end + + @doc false + # Pure kernel: true when MobBridge.kt still has the pre-fix declaration + # (bare `external fun`, no `@JvmStatic`) rather than the corrected one. + # Public for tests. + @spec __component_event_jni_mismatched__(String.t()) :: boolean() + def __component_event_jni_mismatched__(content) do + String.contains?(content, "external fun nativeDeliverComponentEvent") and + not String.contains?(content, "@JvmStatic external fun nativeDeliverComponentEvent") + end + # ── Plugin build options ────────────────────────────────────────────────────── # # When activated plugins contribute native code, the native build passes diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 9bf1640..86fe3c6 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -45,4 +45,30 @@ defmodule Mix.Tasks.Mob.DoctorTest do ["plugin_c_nifs"] end end + + describe "__component_event_jni_mismatched__/1 (MOB-98 JNI owner check)" do + test "flags the pre-fix declaration (bare external fun, no @JvmStatic)" do + pre_fix = """ + object MobNativeViewRegistry { + external fun nativeDeliverComponentEvent(handle: Int, event: String, payloadJson: String) + } + """ + + assert Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__(pre_fix) + end + + test "does not flag the corrected declaration (@JvmStatic, owned by MobBridge)" do + fixed = """ + object MobBridge { + @JvmStatic external fun nativeDeliverComponentEvent(handle: Int, event: String, payloadJson: String) + } + """ + + refute Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__(fixed) + end + + test "does not flag a file that doesn't declare the callback at all" do + refute Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__("object MobBridge {}") + end + end end From b27123a4108594c847e5d874acc49bdcfec24878 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 25 Aug 2026 17:39:53 -0600 Subject: [PATCH 2/2] MOB-98 review fixes: false-positive-proof JNI check, write the missing ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From code review on PR #45: - __component_event_jni_mismatched__/1 used a plain String.contains? requiring "@JvmStatic external fun nativeDeliverComponentEvent" on one line. A dev hand-porting the fix with @JvmStatic on its own line above external fun — idiomatic Kotlin style — got permanently flagged as still broken, since mix mob.doctor only warns and never re-checks a fix it can't see was applied. Switched to a runtime- compiled regex tolerant of the annotation/declaration split across lines (Regex.compile!/1, not a ~r// literal — see mob's AGENTS.md rule #9). - The comment pointed at mob_dev/decisions/ for "why this repo doesn't auto-patch hand-editable native source" — no ADR there actually covered it; the only real precedent was an inline docstring in enable.ex. Wrote decisions/2026-08-25-detect-dont-autopatch-native-source.md documenting the decision properly (per this repo's own decision-log convention: record it the moment you make the call, not later) and pointed the comment at the real file. --- ...-25-detect-dont-autopatch-native-source.md | 62 +++++++++++++++++++ lib/mix/tasks/mob.doctor.ex | 19 ++++-- test/mix/tasks/mob_doctor_test.exs | 14 +++++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 decisions/2026-08-25-detect-dont-autopatch-native-source.md diff --git a/decisions/2026-08-25-detect-dont-autopatch-native-source.md b/decisions/2026-08-25-detect-dont-autopatch-native-source.md new file mode 100644 index 0000000..805c836 --- /dev/null +++ b/decisions/2026-08-25-detect-dont-autopatch-native-source.md @@ -0,0 +1,62 @@ +# Already-generated apps get a `mix mob.doctor` warning, not an auto-patch + +- Date: 2026-08-25 +- Status: accepted + +## Context + +MOB-98's Android JNI owner mismatch (`nativeDeliverComponentEvent` +declared on the wrong Kotlin object) was fixed in `mob_new`'s +`MobBridge.kt.eex` template, but that fix only reaches projects +generated *after* the fix lands — `mix mob.new` renders the template +once at generation time; nothing re-syncs an already-generated app's +`MobBridge.kt` when the template changes later. Every prior template +fix in this repo has hit the same shape (see `enable.ex`'s +`detect_stale_pythonx_templates/2`, which predates this decision by +inheriting the pattern without writing it down). + +The tempting fix is a blind overwrite: detect the stale pattern, patch +it, done. That's wrong here specifically because `MobBridge.kt` (and +the other native template outputs) are **hand-editable** — a real app +routinely customizes them after generation. A blind overwrite risks +silently destroying those edits, which is a far worse failure mode +than "the fix requires a manual step." + +## Decision + +Detect known template drift and **warn with fix instructions**; never +auto-patch native source a user may have hand-edited. This repo now +has two instances of the pattern: + +- `MobDev.Enable.detect_stale_pythonx_templates/2`, wired into `mix + mob.enable`'s python check — reports missing blocks, tells the user + to regenerate or copy the block from the template. +- `Mix.Tasks.Mob.Doctor.check_component_event_jni/0` (this file) — + reports the pre-fix JNI declaration shape, tells the user to port + the fix from a fresh `mix mob.new` app or `mob_new`'s + `MobBridge.kt.eex` directly. + +Any future "template fixed, existing apps still broken" situation +should follow this same shape: a pure detection function + a `mix +mob.doctor` (or equivalent) warning with the concrete fix, not an +auto-patch. + +## Consequences + +- A user must take a manual step (hand-port, or regenerate and + re-apply their own customizations) to receive a template fix in an + existing project. This is real friction, accepted deliberately in + exchange for never risking a silent overwrite of hand-edited native + code. +- Detection logic (e.g. + `Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__/1`) must + stay tolerant of reasonable hand-applied variations of the fix (e.g. + an annotation on its own line vs. the same line as the declaration) + — a false positive here tells a user "still broken" forever, since + `mix mob.doctor` only warns and never re-checks itself against a fix + it can't see was actually applied. +- If template drift becomes common enough that manual porting is a + recurring burden, the next step is a real diff/merge mechanism + (unlike `on_exists: :skip`, which `mix mob.adopt`'s installers use + today only for filling in *missing* files, not re-syncing changed + ones) — not attempted here; out of scope for this decision. diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 791912b..a560d43 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -589,9 +589,10 @@ defmodule Mix.Tasks.Mob.Doctor do # method by its declaring class, so the mismatch only surfaces as an # UnsatisfiedLinkError the first time a real tier-2 native component fires an # event. mob_new's template fix doesn't reach already-generated projects (see - # mob_dev/decisions/ for why this repo doesn't auto-patch hand-editable native - # source) — this check exists so it's caught by `mix mob.doctor` instead of a - # crash on first real interaction. + # decisions/2026-08-25-detect-dont-autopatch-native-source.md for why this + # repo doesn't auto-patch hand-editable native source) — this check exists + # so it's caught by `mix mob.doctor` instead of a crash on first real + # interaction. defp check_component_event_jni do "android/app/src/main/java/**/MobBridge.kt" |> Path.wildcard() @@ -625,10 +626,20 @@ defmodule Mix.Tasks.Mob.Doctor do # Pure kernel: true when MobBridge.kt still has the pre-fix declaration # (bare `external fun`, no `@JvmStatic`) rather than the corrected one. # Public for tests. + # + # @JvmStatic and `external fun` may be on the same line or split across + # two (idiomatic Kotlin puts annotations on their own line) — a plain + # String.contains? on the one-line form flagged a correctly hand-ported + # fix as still broken forever, since mix mob.doctor only warns and never + # re-checks itself. Regex.compile!/1 (runtime), not a ~r// literal — see + # mob's AGENTS.md rule #9: compile-time ~r// bakes a call to + # :re.import/1, removed in OTP 28. @spec __component_event_jni_mismatched__(String.t()) :: boolean() def __component_event_jni_mismatched__(content) do + fixed = Regex.compile!("@JvmStatic\\s+external\\s+fun\\s+nativeDeliverComponentEvent") + String.contains?(content, "external fun nativeDeliverComponentEvent") and - not String.contains?(content, "@JvmStatic external fun nativeDeliverComponentEvent") + not Regex.match?(fixed, content) end # ── Plugin build options ────────────────────────────────────────────────────── diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 86fe3c6..6b44fde 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -70,5 +70,19 @@ defmodule Mix.Tasks.Mob.DoctorTest do test "does not flag a file that doesn't declare the callback at all" do refute Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__("object MobBridge {}") end + + test "does not flag @JvmStatic on its own line above external fun (idiomatic Kotlin)" do + # mix mob.doctor only warns — it never re-checks a hand-applied fix, so + # a false positive here would tell a dev "still broken" forever even + # after they correctly ported it in the idiomatic two-line style. + fixed = """ + object MobBridge { + @JvmStatic + external fun nativeDeliverComponentEvent(handle: Int, event: String, payloadJson: String) + } + """ + + refute Mix.Tasks.Mob.Doctor.__component_event_jni_mismatched__(fixed) + end end end