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
62 changes: 62 additions & 0 deletions decisions/2026-08-25-detect-dont-autopatch-native-source.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 63 additions & 1 deletion lib/mix/tasks/mob.doctor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -573,13 +573,75 @@ 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
# 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()
|> 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.
#
# @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 Regex.match?(fixed, content)
end

# ── Plugin build options ──────────────────────────────────────────────────────
#
# When activated plugins contribute native code, the native build passes
Expand Down
40 changes: 40 additions & 0 deletions test/mix/tasks/mob_doctor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,44 @@ 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

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
Loading