MOB-128: Android :scroll composes only what is on screen - #45
Closed
GenericJam wants to merge 178 commits into
Closed
MOB-128: Android :scroll composes only what is on screen#45GenericJam wants to merge 178 commits into
GenericJam wants to merge 178 commits into
Conversation
Templates: vendor_usb peripheral block in Android codegen
Companion to mob_dev migration/phase-2-iter-13b-dev. The iOS-simulator
build glue (mix compile, BEAM copies, exqlite NIF cross-compile, Pythonx
setup, Elixir/EEx stdlib, OTP runtime sync, enif_keepalive generation)
all moved into MobDev.NativeBuild. Generated projects no longer ship
ios/build.sh.
Removed:
* priv/templates/mob.new/ios/build.sh.eex (288 lines)
* MobNew.LiveViewPatcher.liveview_build_sh_content/2 + caller
overwrite_liveview_build_sh in ProjectGenerator (~307 lines)
* Corresponding test describe blocks (build.sh.eex content checks
+ LV liveview_build_sh_content/2 tests)
* `ios/build.sh` from @executable_files / executable_templates lists
Updated tests:
* Generator now asserts ios/build.sh is NOT generated (was: many
asserts on its content, all stale).
* CMakeLists.txt PRIVATE indent assertion bumped from 4→8 spaces
to match the actual template (pre-existing test bug surfaced
while triaging the iter 13b suite).
…sions Closes three items from mob/issues.md: #1 — Phoenix LiveReload `mac_listener` warnings on iOS device. The host binary isn't bundled and couldn't watch a sandboxed iOS filesystem anyway. Endpoint config in `mob_app.ex` now sets `code_reloader: false`, `watchers: []`, `live_reload: false`. #2 — esbuild + tailwind "version not configured" warnings on-device. Both are dev-time asset compilers that get pulled in as runtime apps; their host config (`config/dev.exs`) isn't bundled. Set their version constants directly via `Application.put_env` before `ensure_all_started`. They never run, just stop warning. #4 — Hardcoded port 4200 collided when two Mob LV apps were installed on the same device (Bandit returns :eaddrinuse, endpoint supervisor crashes, BEAM dies). The on-device default is now `4200 + :erlang.phash2(:<app_name>, 800)` — deterministic per-app range 4200..4999, p<0.5% collision odds at five installed apps. `Mob.LiveView.local_url/1` reads the same env, so the WebView URL stays in sync automatically. Generated `mob.exs` now ships `# config :mob, liveview_port: 4200` commented out — uncomment to pin a specific value (e.g. for a test harness). Tests updated to assert the new shape instead of the old `4200` literal.
Replace the regex on user's mix.exs source in
`MobNew.LiveViewPatcher.inject_deps/3` with Sourceror-based AST
manipulation. The old version matched `defp deps do\s*\[` and
inserted dep tuples at the head of the list — fragile when phx.new's
generated mix.exs varied across Phoenix versions or formatter configs.
The new flow:
* `Sourceror.parse_string(content)` — full AST with comments/meta
preserved for round-trip.
* `Macro.prewalk` to find `def(p) deps do [...] end`.
* Append the parsed dep tuples to the list inside the `do` body.
* `Sourceror.to_string(ast)` — round-trip back to source.
Idempotency now scans the AST for `:mob` declarations regardless of
indentation or trailing-comma shape (the old `String.contains?` checks
for `:mob,`/`:mob ` substrings were brittle and could miss
`{:mob, "~> 0.5", only: :dev}` shapes if reformatted).
Bails out safely when the AST walk can't find a deps function (e.g.
`defp deps, do: [...]` shorthand isn't in scope for iter 1) — returns
the content unchanged so downstream code sees a no-op rather than a
mangled file.
Adds `sourceror ~> 1.0` to mob_new deps. mob_new doesn't use Igniter
directly here — Igniter's `Project.Deps.add_dep` is tied to igniter
state and the heavier Igniter.Mix.Task lifecycle, both overkill for a
one-shot patch. Direct Sourceror is leaner and the same underlying
AST machinery Igniter uses.
3 new tests under the inject_deps describe — empty deps list, the
shorthand `defp deps, do:` form (no-op), and a round-trip parse to
prove the output is valid Elixir. All 67 LV patcher tests pass
(64 existing + 3 new); 221/221 mob_new tests pass overall.
Smoke-tested via the test suite's integration tests which call into
the full LV generation flow.
AGENTS.md gotchas section gains two bullets:
- inject_deps uses Sourceror AST, not regex. When extending,
work through the prewalk; don't reach for a new regex on
`defp deps do\s*\[`. Note that the shorthand
`defp deps, do: [...]` form is unmatched today and waits on
a real-project hit.
- sourceror is a runtime dep (added Phase 5 iter 1), bundled
into the .ez archive. Adds ~1 MB; consider archive bloat
before piling on more AST tooling.
README intentionally untouched — `inject_deps` is internal to
the generator and not part of the user-facing API surface, so
the public docs don't change shape.
Closes Phase 5. The stop criterion from the migration plan — "no
more regex-patched Elixir source in the LV generator" — was hit in
iter 1; this is the cleanup. 224/224 mob_new tests pass on master.
mob_new's iOS sim build template (priv/templates/mob.new/ios/build.zig.eex)
gains an `addZigObject` helper that compiles a single .zig source into a
relocatable object with C-ABI exports. The driver_tab call site now
auto-detects the source extension:
if (std.mem.endsWith(u8, driver_tab, ".zig"))
addZigObject(b, ...)
else
addCObject(b, ...)
Existing projects with driver_tab_*.c keep using the C path with all
the iOS-SDK include flags. Once a project switches to .zig (iter 3
will make `mix mob.regen_driver_tab` emit Zig from the manifest), the
addZigObject branch fires — no include flags needed since the .zig
file declares its own C-ABI types via `extern struct`.
build_device.zig is untouched in this iter. Its driver_tab compile
includes a conditional `-DMOB_STATIC_SQLITE_NIF` C-preprocessor flag
that the .zig version handles via a comptime `sqlite_static` constant
threaded through Zig's `b.addOptions()` mechanism. That wiring lands
in iter 3 alongside the regen task update.
Verified: rendered template through EEx with realistic assigns,
`zig ast-check` against the output passes (no syntax errors). 224/224
mob_new tests pass.
…static
mob_new's iOS device build template gains the same .zig auto-detection
the sim template got in iter 2, plus the comptime sqlite_static
threading the device build needs:
const driver_tab_lp = if (.zig source) blk: {
const opts = b.addOptions();
opts.addOption(bool, "sqlite_static", sqlite_static);
break :blk addZigObject(b, .{ ..., .build_options = opts });
} else addCObject(b, .{ ..., .c_flags = driver_tab_flags });
The addZigObject helper in both templates now accepts an optional
`build_options: ?*std.Build.Step.Options` field. When set, it's wired
into the module via `mod.addOptions("build_options", build_opts)` so
the .zig source can `@import("build_options").sqlite_static`. iOS sim's
build.zig was updated to pass an options module with
sqlite_static=false (exqlite is dlopen'd on sim, not statically linked),
keeping the driver_tab.zig source compileable on both targets without
conditional imports.
Verified: rendered both templates through EEx and ran `zig ast-check`
against the output. Both pass. 224/224 mob_new tests pass.
…ig-by-default
Template comments + the `b.option` help-string in the two iOS build
templates updated to acknowledge Zig as the default driver_tab format:
- Header comment: "driver_tab_ios.zig (or .c — extension
auto-detected at compile)"
- `-Ddriver_tab=` help-string: "Absolute path to driver_tab_ios.{zig,c}"
- The if/else routing comment notes Zig is default with .c as the
`--format c` opt-out.
No build-logic changes — the addZigObject + addCObject auto-detect
from iter 3 already handles both formats correctly.
Mirror the iOS templates' Phase 6a iter 2/3 pattern on Android:
add an `addZigObject` helper and auto-detect file extension in the
source iteration loop. The four sources the Android build handles —
driver_tab_android, mob_nif, mob_beam, beam_jni — can each be a .zig
file now without touching the call site.
Why iter 1 is just the build plumbing:
mob_beam.c (~540 lines) + mob_nif.c (~2570 lines) total ~3100 lines
with non-trivial JNI ergonomics in Zig (vtable access via
`(*env)->NewGlobalRef(env, …)` becomes awkward function-pointer
dereference, etc.). Faithful translation is a multi-iter project.
This iter ships the toolchain so the actual translation can move at
the granularity of "1-2 functions per commit" rather than waiting on
build-system support. driver_tab_android.zig (already shipped in
Phase 6a iter 1 as the reference impl) becomes consumable on Android
via the same auto-detect chain that the iOS path uses.
`b.option` help-string + header comment updated to acknowledge
`.{zig,c}` extension. mob_dev's NativeBuild already resolves
driver_tab_android.zig over .c since Phase 6a iter 3, so the new
default chain works end-to-end without any further mob_dev changes.
Verified: rendered Android build.zig.eex through EEx +
`zig ast-check` against the output — passes. 224/224 mob_new tests
pass.
Next iters can port mob_beam.c → mob_beam.zig (smaller, more
self-contained) before tackling mob_nif.c.
Companion change to mob's iter-2 port. The per-app Android build.zig
template now:
* references `$MOB_DIR/android/jni/mob_beam.zig` (was `.c`)
* passes `build_options` to `addZigObject` for mob_beam, threading
`no_beam` (false) and `beam_flags_mode` ("nerves_full") for the
comptime gates inside mob_beam.zig. Other .zig sources
(driver_tab_android.zig today) don't need options; null is fine.
`ZigObjectOptions` already accepted optional `build_options` from
iter 1's plumbing, so the helper itself is unchanged.
Verified: `zig ast-check` on the rendered build.zig passes;
224/224 mob_new tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Companion to mob's iter-3a port. Adds a second source spec for mob_nif.zig — installed as <abi>/mob_nif_zig.o to keep it distinct from mob_nif.c's <abi>/mob_nif.o. Both .o files contribute to the final lib<app>.so; the C-side nif_funcs[] table resolves the Zig exports via extern declarations near the top of mob_nif.c. No new build_options threading is needed for mob_nif.zig today — it imports mob_zig.zig and mob_erts.zig for FFI surface but doesn't read any comptime config. If later sub-iters need build_options (e.g. for feature gates), the per-source decision pattern from iter 2's mob_beam wiring extends naturally. Verified: zig ast-check on the rendered build.zig passes; 224/224 mob_new tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Companion to mob's iter-3d finale. The Android build template now compiles `mob_nif.zig` as the sole `mob_nif` source (no more split mob_nif / mob_nif_zig pair). The .c file in mob is gone; the .o filename collision concern that motivated the dual-source iter-3a wiring is moot. Header comment + source list updated. The only remaining `.c` in the Android native build is `beam_jni.c` — that's the per-app stub generated by mob_new (JNI entrypoints + g_jvm/g_activity globals), intentionally kept as C so authors can read it without learning Zig. Verified: zig ast-check on the rendered build.zig passes; 224/224 mob_new tests pass; credo strict clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs in the Android build.zig.eex template surfaced by the
Phase 6b iter 3d end-to-end smoke deploy (companion to mob master
50f87bb):
1. `.pic = true` missing on the addZigObject createModule call.
Without it, .rodata-to-local-symbol references in Zig sources
(e.g. mob_beam.zig's `default_flags` array of pointers to
string literals) emit R_AARCH64_ABS64 relocations against
local symbols. ld.lld refuses those in a shared library:
ld.lld: error: relocation R_AARCH64_ABS64 cannot be used
against local symbol; recompile with -fPIC
2. `addLink` produced the cp step that installs `lib<app>.so` into
jniLibs/, but didn't return it. `addExqliteLink` then
referenced the installed path via a plain `addArg` (literal
string, not a LazyPath), so Zig's build graph had no ordering
edge between the two link steps. They ran in parallel and
exqlite's clang errored out with `no such file or directory:
'.../jniLibs/arm64-v8a/lib<app>.so'`. Fix: `addLink` now
returns the cp step; `ExqliteLinkOptions.depends_on` carries
that edge into exqlite's link.
Verified: rendered build.zig passes `zig ast-check`; 224/224
mob_new tests pass; credo strict clean. Companion mob commit
50f87bb verified end-to-end against emulator-5556.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Surfaced by Elixir 1.20-rc.4 / OTP 28 in every newly-generated mob
app's compile output:
src/test_migration.erl:17:15: warning: 'catch ...' is deprecated;
please use 'try ... catch ... end' instead.
Switch to `try Fun() catch Class:Reason -> {Class, Reason} end`.
Preserves the original intent (don't let one failed start_application
abort the boot before the log line below prints) and gives a readable
tagged tuple to format instead of the legacy `{'EXIT', _}` shape.
Existing apps generated by older mob_new pick up the fix on next
`mix mob.new`; users running an already-generated app can hand-patch
or regenerate.
mix test: 224 tests, 0 failures
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Before: --local only swapped the generated mix.exs deps to path:
references. Templates still came from `:code.priv_dir(:mob_new)`,
which resolves to the installed archive's priv dir — so users
running `mix mob.new --local foo` from outside mob_new's checkout
got fresh deps configuration with stale templates whenever the
installed archive lagged master.
This bit twice in one session:
- Erlang `(catch ...)` template was fixed in mob_new master
several commits ago but regenerated projects still had the
deprecated syntax.
- Android `build.zig.eex` got `.pic = true` added master-side
but regenerated projects still hit the R_AARCH64_ABS64 link
error on every Android build.
After: when --local is set, look up a reachable mob_new checkout
via MOB_NEW_DIR env (with ~/code/mob_new as fallback) and use ITS
priv/templates/mob.new for rendering. If neither path is usable,
fall through to the installed archive — same behaviour as before
for users who don't have a local mob_new checkout.
API:
- New public-for-testing MobNew.ProjectGenerator.local_mob_new_priv/1
encapsulates the lookup.
- templates_root/1 and static_root/1 now take opts and dispatch
through priv_root/1.
- One-time log line ("* --local: using templates from <path>")
when local templates resolve, so users can confirm.
Tests:
+5 covering local_mob_new_priv/1 across the lookup matrix.
mix test: 229 tests, 0 failures (was 224; +5 from new suite)
mix credo --strict: clean
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two warnings surfaced by a clean compile under 1.20-rc.4:
lib/mob_new/live_view_patcher.ex:176
`node` bound in a match-clause but the body destructures
head/body/args and never references `node`. Prefix with
underscore.
lib/mob_new/project_generator.ex:1102
`if dest_rel in @executable_files` where `@executable_files = []`
— the type checker correctly proves it's always false. The
placeholder was left from Phase 2 iter 13b when iOS sim
build.sh.eex (the only previously-executable template) got
eliminated. Delete the dead check AND the module attribute;
add a comment noting where to re-add a chmod hook when a
future template needs it. The generate/4 path (~line 508) keeps
its local `executable_templates` for the same reason — that
one's a dynamic local, not narrowed by the type checker.
mix test: 229 tests, 0 failures
mix credo --strict: clean
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…d output Multiple cycles this session of "regenerate test_migration → hit the same warning we already fixed in master" because some lookup or path-resolution wasn't pinned by a test. Document the pattern (test against tmpdir fixtures, test AST patchers, test --local-style lookups) so future agents extend coverage instead of re-finding the same bugs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous comment claimed the function was missing from "OTP 17.0" which is misleading — it's not the OTP release, it's that the iOS- device tarball's libbeam.a still ships erl_posix_str.o (the legacy file) while sim/Android tarballs ship only erl_errno_str.o (the modern one without the _unknown reference). Updated comment now states the actual reason and points at the upstream diagnosis in mob_dev's generate_erl_errno_compat_stub/1 docstring + tests.
Companion to mob_dev 8c22821. Both iOS templates (build_device.zig
for device, build.zig for sim) now consume three new -D options
that mob_dev's native build pipeline passes:
-Dproject_root=<absolute project root>
-Dproject_c_nifs=<comma-separated C NIF names>
-Dproject_rust_libs=<comma-separated absolute .a paths>
For each C NIF name, the template emits an addCObject block
compiling `<project_root>/c_src/<name>.c` with the mandatory
`-DSTATIC_ERLANG_NIF` and `-DSTATIC_ERLANG_NIF_LIBNAME=<name>`
flags. The libname define overrides the ERL_NIF_INIT macro's
symbol-name mangling so the init function ends up named
`<name>_nif_init` (matching what driver_tab_ios.zig declares).
For each Rust .a path, the template adds the file to the linker's
arg list inside addLink. mob_dev pre-cross-compiles the Rust crate
with `cargo rustc --release --target aarch64-apple-ios{,-sim}
--crate-type staticlib` before invoking zig, so the .a is on disk
by the time addLink runs.
Both option lists default to "" so projects without project-side
NIFs are unaffected — the iterators no-op on empty strings.
End-to-end empirically verified on physical iPhone:
- `mob.add_nif greet_c --type c --demo` →
Mob.Test result: ~c"Hello from C!"
- `mob.add_nif greet_rust --type rustler --demo` →
Mob.Test result: "Hello from Rust!"
No hand-editing of build_device.zig required.
Newly-generated projects now wire EMLX into the Zig iOS build the same
way mob_dev's NativeBuild does. When mlx_static=true is passed:
* driver_tab compile gets -DMOB_STATIC_EMLX_NIF (C path) and an
emlx_static comptime const via b.addOptions (Zig path).
* Link line picks up <mlx_dir>/lib/libemlx.a + <mlx_dir>/lib/libmlx.a
and -framework Accelerate (Apple's vectorized BLAS/LAPACK used by
MLX-CPU).
* The driver_tab compile-flags list is now a buffered ArrayList so
additional -D guards can be appended without reshuffling the file.
Templates default mlx_static=false; nothing happens for projects that
haven't opted in via `mix mob.enable mlx`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the mob_new half of issue #19. The Android `jni/build.zig` and `jni/CMakeLists.txt` templates now do the same auto-wiring their iOS counterparts (build_device.zig.eex / build.zig.eex) landed in commit be2ad35. ## jni/build.zig.eex * New `-Dproject_c_nifs` and `-Dproject_rust_libs` build options (declared, defaulted to ""), threaded in by mob_dev's `project_nif_zig_args(:android)`. * After the per-app static sources loop, iterate `project_c_nifs` (comma-separated names). For each: addCObject with `<project_root>/c_src/<name>.c`, extending the base c_flags by `-DSTATIC_ERLANG_NIF_LIBNAME=<name>`. The base c_flags already carry `-DSTATIC_ERLANG_NIF` so we only need the libname override (otherwise the ERL_NIF_INIT macro mangles the BEAM module name into something that won't compile). Each obj installs to `zig-out/<abi>/<name>.o` and joins `obj_paths` for linking. * New `-Denif_keepalive=<path>` option mirroring the iOS template. When non-empty, compile the file as an extra obj. mob_dev writes it (and passes the path) for projects that ship dynamic NIFs (Pythonx) or static NIFs that resolve the enif_* dispatch table via dlsym (Rustler 0.37). Without that obj `-Wl,--gc-sections` strips the enif_* symbols and dlsym aborts at NIF load. Optional here because vanilla Android projects without a dynamic NIF surface don't generate the file at all. * `addLink` LinkOptions gains a `project_rust_libs` field. The fn body iterates the comma-separated list and appends each `.a` archive to the linker line (outside the --whole-archive bracket; the static-NIF table's strong undefined references to `<name>_nif_init` pull the archive in). Mirrors iOS's `addLink` path. ## jni/CMakeLists.txt.eex Path 3 (the non-Mix fallback for Android Studio "Sync Project" or `./gradlew assembleDebug` without Mix): pick up `c_src/*.c` via `file(GLOB PROJECT_NIF_C_SRCS …)`, append them to the `add_library SHARED …` source list, and set per-source `STATIC_ERLANG_NIF_LIBNAME=<name>` via `set_property(SOURCE … PROPERTY COMPILE_DEFINITIONS …)`. The GLOB keeps CMake working when NIFs are added or removed without regenerating this file. Rust/Zig NIFs aren't covered by path 3 — those need mob_dev's cross-compile step that only runs under Mix. ## Verified End-to-end on a fresh `mix mob.new android_nif_demo --android` project: ``` $ mix mob.add_nif greet_c --type c --demo --yes $ mix mob.deploy --native --device emulator-5554 $ iex> :rpc.call(node, AndroidNifDemo.Nifs.GreetC, :greet, [], 5000) ~c"Hello from C!" ``` Rust and Zig demos compile + link through this template path; their runtime blockers (Rustler dlsym, Zigler/NDK header parse) are out-of-scope for issue #19 per its scope guardrails.
Generated projects now include: 1. \`credo\` and \`ex_slop\` in mix.exs deps (only: [:dev, :test]) 2. A \`.credo.exs\` with ExSlop wired in as a check New users running \`mix mob.new my_app\` get the AI-slop linter out of the box. They run \`mix credo --strict\` to see what shipped. No agent intervention needed to wire it up later. 229 existing mob_new tests still pass (verified via mix test). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ex_slop to mob_new's deps (the same recommendation we just shipped
to generated projects via the template). Generated a baseline .credo.exs
via \`mix credo gen.config\`, set strict: true, added \`{ExSlop, []}\`
to enabled, and excluded priv/templates/ (template files have EEx
syntax that Credo can't parse).
70 checks running on the 8 source files, 3 baseline findings (2x
String.graphemes/1 vs String.length/1 in LiveViewPatcher, 1x Path.expand
in a test). Not fixing in this commit.
Updated CLAUDE.md pre-commit checklist to mention ExSlop alongside
the existing \`mix credo --strict\` step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… of graphemes \`String.graphemes |> Enum.count(==)\` was flagged by ex_slop. The literal "use String.length/1" suggestion doesn't apply (we're counting specific characters, not total length), but the underlying complaint is fair — graphemes is overkill for ASCII char counting. Switched to \`:binary.matches/2\` which is O(n) bytes with no intermediate list. 228 tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same as the mob_dev companion — single source of truth in mob/AGENTS.md; this file updates the entry-point pointer so agents see it before touching mob_new code. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Matches the mob bump. Also updates the generator template default (lib/mob_new/project_generator.ex:377), so projects created via \`mix mob.new\` get the new versions in their generated \`.tool-versions\`. Verified mob_new compiles + 228 tests pass under the new toolchain. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Android link step took the comma-separated `project_rust_libs`
paths and called `run.addArg(a_path)` — a string argument, not a
tracked file input. Zig's run cache hashes the string, but not the
.a contents, so rebuilding a project-side Rust crate produced a new
libgreet.a that the link step ignored as UP-TO-DATE. The .so in
jniLibs/ remained linked against the previous .a even after a fresh
gradle build.
Switch to `run.addFileArg(.{ .cwd_relative = a_path })` so the
ContentHash picks up the archive bytes. Mirrors the existing
treatment of compiled object files immediately above.
Symptom that surfaced the bug: a Rust NIF init panic was identical
across two patch generations because the second patch's .a was
built fine but never linked into libnif_combo.so.
…l three)
Same bug as the android template fix in 917e7e4: both iOS build.zig
templates (sim + device) passed `project_rust_libs` paths via
`run.addArg(path)`. Zig's run cache hashes the string but not the .a
contents, so rebuilding a project-side Rust crate produces a fresh .a
that the link step ignores as UP-TO-DATE. The resulting binary stays
linked against the previous .a — same silent-staleness failure mode
already fixed on Android, just on iOS this time.
Switch all three templates (android, ios sim, ios device) to
`addFileArg(.{ .cwd_relative = path })`.
Add three tests in project_generator_test.exs that generate a project,
read each rendered build.zig, locate the project_rust_libs loop body
(strip line comments so the assertion isn't fooled by the explanatory
comment we added), and assert it uses `addFileArg` and not `addArg`.
Verified the tests fail loudly when a template regresses to `addArg`.
Credo's EnumPipe check flagged the Enum.map |> Enum.join I introduced in 5cfe052. Equivalent behaviour with one fewer pass.
Ships the mob_new half of MOB-6 (#26): generated Android apps now wire the :magnetometer sensor (mob 0.7.14) — motion_start parses the requested sensor set and only registers TYPE_MAGNETIC_FIELD + TYPE_ROTATION_VECTOR when asked, routing through nativeDeliverMotionMag. Requires mob 0.7.14+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kotlin half of MOB-15 (pairs with GenericJam/mob PR). Generated apps get a MobBridge.torch(String) that toggles the rear-camera torch via CameraManager.setTorchMode — no capture session, no CAMERA permission. Finds a camera with a flash unit, no-ops on flash-less devices, and swallows the transient CameraAccessException/IllegalArgumentException rather than crashing. - MobBridge.kt.eex: import CameraManager/CameraCharacteristics + torch(String) - AndroidManifest.xml.eex: uses-feature camera.flash required=false (so flash-less devices still install; no permission needed) - project_generator_test.exs: positive guard the torch bridge + manifest emit Requires the mob release that carries nif_torch. Device verification pending. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the mob_new half of MOB-15 (#27): generated Android apps wire the torch via MobBridge.torch(String) -> CameraManager.setTorchMode (no session, no permission) + a not-required camera.flash uses-feature. Requires mob 0.7.15+. Device-verified on moto g power (2021). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add mob_audio_capture to the default :trusted_plugins map in the mob.new template, with the shared mob first-party fingerprint (same as every other first-party plugin). Now that mob_audio_capture ships signed with the shared key (GenericJam/mob_audio_capture#2), a generated app activates it without an extra `mix mob.plugin.trust` step — parity with mob_camera/screencast/etc. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-14: Android network-connectivity callback for Mob.Device.network_state
Companion to mob PR #62 (one issue, two repos). Wires the Android side of
the network/connectivity capability:
- MainActivity.kt: register a ConnectivityManager.NetworkCallback in onCreate
(unregister in onDestroy) that maps NetworkCapabilities -> online/transport/
expensive and calls nativeNotifyConnectivity. registerDefaultNetworkCallback
fires an initial onCapabilitiesChanged, seeding the BEAM-side cache at start.
- beam_jni.c: nativeNotifyConnectivity trampoline -> mob_send_connectivity_changed
(exported by mob's mob_nif.zig).
- AndroidManifest.xml: ACCESS_NETWORK_STATE (normal install-time permission).
Verified: generate-and-ktlint (mix test --only lint) green; credo clean;
generator suite green (the pre-existing NDK-skip-tag failure is unrelated).
Android native compile + on-device runtime pending (the Android SDK is not
accessible in this environment); iOS is fully device-verified in mob #62.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-14: address review — onLost re-query + beam_jni OOM guard
- MainActivity.kt: onLost no longer blindly reports offline. On a wifi->cellular
handoff the lost default's onLost can arrive after the new default settled,
leaving us stuck offline; re-check activeNetwork/getNetworkCapabilities and
report that instead. Shared mapping extracted to pushConnectivity/1.
- beam_jni.c: nativeNotifyConnectivity only calls ReleaseStringUTFChars when
GetStringUTFChars actually returned chars (guards the OOM/NULL edge; passes
"none" to the BEAM in that case).
Rebuilt + device-verified on moto g power (2021): network_state/0 =>
%{online: true, transport: :wifi, expensive: false}. ktlint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-14: pass NET_CAPABILITY_VALIDATED through to network_state
pushConnectivity now reads caps.hasCapability(NET_CAPABILITY_VALIDATED) and
threads it through nativeNotifyConnectivity -> beam_jni.c trampoline ->
mob_send_connectivity_changed, so Mob.Device.network_state/0 reports Android's
real-internet-reachability probe as `validated`. (iOS reports :unavailable.)
Device-verified on moto g power (2021): validated: true on a real wifi link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-14: test the generated connectivity wiring (MainActivity extern <-> beam_jni)
The external_fun_jni_consistency lint only covers MobBridge externs, so the
MainActivity-declared nativeNotifyConnectivity <-> beam_jni.c thunk pairing was
untested — a dropped callback would pass CI. Mirror the nativeNotifyOrientation
assertions: MainActivity.kt has the extern + NET_CAPABILITY_VALIDATED, beam_jni.c
has the Java_..._MainActivity_nativeNotifyConnectivity thunk + the
mob_send_connectivity_changed call, and the manifest declares ACCESS_NETWORK_STATE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the mob_new half of MOB-14 (#28): MainActivity ConnectivityManager .NetworkCallback -> beam_jni.c nativeNotifyConnectivity -> mob_send_connectivity _changed, feeding Mob.Device.network_state/0 (mob 0.7.16). ACCESS_NETWORK_STATE added; generator test covers the wiring. Device-verified on moto g power (2021). Also ships MOB-34 (#29): mob_audio_capture pre-trusted in generated apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#30) Kotlin half of MOB-20 (pairs with GenericJam/mob PR). Generated apps get a MobBridge.keepAwake(Int) that toggles the window's FLAG_KEEP_SCREEN_ON on the UI thread (no permission). Adds the android.view.WindowManager import. - MobBridge.kt.eex: import WindowManager + keepAwake(Int) - project_generator_test.exs: positive guard the keep-awake bridge emits Requires the mob release that carries nif_device_keep_awake. Device verify pending. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the mob_new half of MOB-20 (#30): generated Android apps wire Mob.Device.keep_awake/1 via MobBridge.keepAwake(Int) -> FLAG_KEEP_SCREEN_ON (no permission). Requires mob 0.7.17+. Device-verified on moto g power (2021). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds audio_start_input_metering / audio_input_level / audio_stop_input_metering to the generated MobBridge (MediaRecorder mic metering via getMaxAmplitude). Pairs with the mob-side zig NIF + Mob.Audio.input_level (GenericJam/mob#67) — the agent "ears" primitive. Device-verified on a moto g power (2021): input_level tracks mic level (~-55 dBFS ambient, spikes to -19/-40 dBFS on sound). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s/level) (#25) * Scaffold MobBridge audio output probe methods for Mob.Audio Adds the app-owned Kotlin bridge methods that back mob's new audio output probes (Mob.Audio.output_status/0, output_level/1): - audioOutputStatus(): FloatArray — volume / mute / route / other-audio via AudioManager, returned as float[4] for the NIF to decode. - audioOutputLevel(source): FloatArray? — peak/RMS dB from a short-lived Visualizer on the global output mix (session 0), so it observes audio from native players that bypass Mob.Audio (e.g. a game's own AudioTrack). Returns null on failure (usually RECORD_AUDIO not granted); the mob NIF caches it with cacheOptional so a drifted bridge no-ops. ktlint clean (mix test --only lint); a focused template test pins both methods, the session-0 mix tap, and the peak/RMS measurement mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Audio probe template: meter own player session, not session 0 Device verification (moto g power 2021, Android 11) showed a session-0 Visualizer fails with ERROR_NO_INIT for a normal app even with RECORD_AUDIO + MODIFY_AUDIO_SETTINGS — global output capture is privileged. So audioOutputLevel now meters Mob.Audio's OWN player session (audioPlayer.audioSessionId), which works with RECORD_AUDIO, and returns a length-1 error code (1 unsupported / 2 needs_record_audio / 3 not_playing) for the NIF to map. "mix" is reported unsupported; global capture moves to a separate MediaProjection plugin. Template test updated to assert the own-session tap and that session 0 is not used. ktlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setTheme now also parses _font_fallback (previously any non-Number value in the theme JSON was silently dropped) into MobBridge.fontFallback. fontFamilyProp walks [primary, ...fontFallback] via the new resolveOneFontName, replacing the old empty catch blocks with Log.w on every failed candidate and a final "none resolved" log — a missing/misnamed font used to silently substitute the system font with zero signal. Verified via a fresh `mix mob.new` + `mix mob.install` + `mix mob.deploy --native --android` (real gradlew assembleDebug build, compileDebugKotlin succeeds). Part of the mob fonts feature — see MOB_FONTS.md in the mob repo for the full design. MOB-94
Core still links CameraX directly (build.gradle.eex) for the camera preview, separately from what mob_camera/mob_scanner declare in their own manifests — this copy has to move in lockstep with those or every mob_new-generated app carries the same 16 KB page-alignment warning regardless of which camera plugins are active. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same correction as mob_camera/mob_scanner: 1.6.1 fails Gradle build against this template's toolchain (needs compileSdk 36 + AGP 8.9.1+, has compileSdk 34 / AGP 8.2.0). 1.4.2 carries the 16 KB fix without that requirement — device-verified via mob_plugin_demo (Moto G build + boot, zero native-linkage errors). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MOB-95: bump CameraX to 1.6.1 in the app template for 16 KB alignment
Typeface.create(String, Int) never returns null and rarely throws for an unrecognized family name — per its own docs it silently substitutes Typeface.DEFAULT. resolveOneFontName's try/catch never caught this, so the first fallback candidate always "succeeded" and the chain never walked past it. Found via physical-device verification (Moto G emulator). Detect via reference-equality against Typeface.DEFAULT instead.
Xcode 27 requires scene-based app startup; booting everything in AppDelegate.application:didFinishLaunchingWithOptions: no longer works. Ported the fix already verified externally in the `clarity` app (commits 13dc2ab + 75ff1aa): - AppDelegate: drop the `window` property, strip didFinishLaunchingWithOptions: to a bare `return YES;`. Every other AppDelegate method (orientation mask, push-token handlers) is untouched. - New SceneDelegate (same file): scene:willConnectToSession:options: creates the window via initWithWindowScene: and sets rootViewController/makeKeyAndVisible on every call (a scene can disconnect/reconnect without relaunching the process), but wraps mob_register_plugins()/mob_init_ui()/the BEAM-boot pthread in a `static dispatch_once_t` so the BEAM only ever boots once per process -- a second erl_start in the same process is fatal and mob's native layer has no guard against it. - Info.plist: UISceneConfigurations added alongside the existing UIApplicationSupportsMultipleScenes=false. Verified: mix test (319 passed, incl. the existing AppDelegate content assertions -- they're substring checks, order-independent of which class contains the code, so no test changes needed), mix test --only lint, and a real clang -fsyntax-only pass (iOS Simulator SDK 26.5, not 27 -- unavailable on this machine) against a freshly `mix mob.new`- generated app built from a dev archive of this worktree: zero warnings/errors, and critically no more of the initWithFrame:/ UIScreen.mainScreen deprecation warnings the old code had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two gaps flagged in review, both addressed: 1. Generator tests asserting the rendered AppDelegate.m declares UIWindowSceneDelegate and calls dispatch_once(&<token>, ...) at the actual call site -- not just the dispatch_once_t type declaration, which a careless "simplification" could leave behind after removing the real guard (verified: temporarily stripped the call in the template, confirmed the test fails, restored it). Mirrors the house pattern set by the 16 KB fix's generator test. Also asserts Info.plist's UISceneDelegateClassName. 2. decisions/2026-08-25-uiscene-lifecycle-xcode27.md, explicitly carrying the "existing apps need a hand-port" caveat (same pattern as 2026-06-17-android-16kb-page-size.md) and pointing at MOB-97 / the clarity app commits for anyone porting a different existing app. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MOB-97: port UIScene lifecycle adoption (Xcode 27 requirement)
Ships the mob_new half of the font feature designed and device-verified this session: generated apps' MobBridge.kt now walks mob's font fallback chain, plus the Typeface.DEFAULT reference-equality fix that makes the walk actually trigger (Typeface.create silently substitutes the system default instead of signaling failure for an unknown name). See CHANGELOG.md for the full breakdown.
MobBridge.kt.eex declared nativeDeliverComponentEvent as a bare external fun on MobNativeViewRegistry, but the generated JNI export in beam_jni.c.eex is Java_<pkg>_MobBridge_nativeDeliverComponentEvent. JNI resolves a native method by its declaring class, so a real event from a Compose native component threw UnsatisfiedLinkError. Moved the declaration onto MobBridge as @JvmStatic external fun (matching every other nativeDeliver* callback in the file) and call it through the qualified MobBridge.nativeDeliverComponentEvent(...) from the registry. Also adds MobNew.Templates.Lint.native_funs_owned_by_mob_bridge/1 — the existing external_fun_jni_consistency/2 only checks that Kotlin and C agree on the function NAME, not which class actually owns the Kotlin declaration, so it stayed green through this exact bug. The new check verifies every native fun in MobBridge.kt is actually declared inside object MobBridge. Device-verified on an Android emulator: a real tier-2 native component (Compose Button) fires a tagged event, the app stays alive, and the event reaches handle_event/3.
From code review on PR #35: - native_funs_owned_by_mob_bridge/1 checked pure byte-position containment within object MobBridge's span — an external fun nested inside another class DECLARED inside object MobBridge (e.g. `object MobBridge { class Helper { external fun bar() } }`) sat inside the span and passed, even though JNI would need MobBridge$Helper as the declaring class, not MobBridge. Added brace_depth_between/3: a match only counts as a direct member if net brace depth from the span's open brace to the match is 0. - Wired the check into check_kotlin/1's aggregate — it was only ever exercised by a dedicated test, not the standard lint pass every other Kotlin-relevant check runs as part of. Safe to add: it already no-ops (returns []) for content with no "object MobBridge" block, so running it against MainActivity.kt or any other .kt content can't produce a false positive. - Documented the check in AGENTS.md's "things that bite" list, per repo convention (same commit as the change, not a follow-up).
…ents MOB-98: fix Android JNI owner mismatch for tier-2 native components
…ponents MobBridge.kt.eex declared nativeDeliverComponentEvent on the wrong Kotlin object; JNI resolves native methods by declaring class, so real component events threw UnsatisfiedLinkError. Also adds a brace-depth aware lint check (native_funs_owned_by_mob_bridge) that would have caught this — the existing name-consistency check didn't verify class ownership. (MOB-98) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndroid The mob 0.7.28 fix for component-handle pool exhaustion returns -1 instead of crashing when the pool is full. iOS's MobNativeViewRegistry was updated to skip rendering on -1; this closes the same gap on the generated Android template, which was left uncommitted during that earlier fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured on a Moto G Power, one render at a time so no frame queues behind another: a 200-row screen cost 134 ms of main-thread work per update, 107 ms of it Compose recomposition, against 45 ms for the entire BEAM-plus-NIF pipeline. The native rebuild is the dominant cost, and :scroll composed every child regardless of the viewport — 200 rows composed to show about ten. Vertical :scroll now renders through MobLazyList when its content can be lazified. Two things had to be solved and they are the substance of this change. Mob screens are written scroll > column > rows, so a scroll node usually has exactly ONE child; lazifying its direct children buys nothing because the column underneath still composes every row. When the sole child is a column whose own props are layout-neutral, its children become the items. Only fill_width and fill_height count as neutral — a LazyColumn already spans its container's width and takes its height from the scroll node. Padding, background, align, or an id the harness addresses would be silently dropped by flattening, so those keep the eager path. A lazy container must be measured with a bounded main axis. Column only measures a child against the remaining space when that child is weighted, so fill_height alone left it unbounded. verticalScroll tolerates that; LazyColumn does not — it composes zero items and renders an empty screen. A fill_height scroll child now gets Modifier.weight(1f) from its parent column. MobLazyList is reused rather than a fresh LazyColumn so :scroll inherits the list-state hoisting already there: rememberLazyListState resets to 0 on every BEAM re-render. Main-thread frame cost, p50 / max: 50 rows eager 77.8 / 106.7 ms lazy 83.2 / 120.4 ms 200 rows eager 140.9 / 162.1 ms lazy 81.5 / 107.5 ms 500 rows eager 104.0 / 307.9 ms lazy 91.6 / 110.9 ms The shape matters more than the percentage: lazy cost is flat in list length while eager grows. At 50 rows lazy is slightly worse, which is expected — the list nearly fits on screen so there is little to skip and LazyColumn has setup cost. At 500 rows the eager p50 is unreliable (under backpressure the app draws less often, so fewer heavy frames are sampled); its max of 308 ms against 111 ms is the honest comparison. This does not make Compose skip anything — skipping is separately broken, and a composable with a literally constant argument still recomposes every frame here. Laziness works by composing fewer nodes, not by reusing unchanged ones. Verified by generating an app from the patched template and building it, and by screenshotting every measurement. That last part is not ceremony: an earlier version of this change reported 1617 -> 24 composables and 312 -> 78 ms while rendering a blank list. Both numbers looked like a triumph. Only pixels caught it. No version bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Superseded by the rebase onto current master (0.4.30) — this branch was cut from a stale master and conflicted. Same change, re-verified end to end on the rebase. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Android half of MOB-128, from the MOB-124 rendering-performance epic. No version bump.
Companion PR in
mob: GenericJam/mob#118 (iOS half plus MOB-125/133/135).Why
Measured on a Moto G Power (2021), one render at a time with 2.5 s of quiet between so no frame queues behind another. A 200-row screen cost 134 ms of main-thread work per update, 107 ms of it Compose recomposition, against 45 ms for the entire BEAM-plus-NIF pipeline. The native rebuild is the dominant cost, and
:scrollcomposed every child regardless of the viewport — 200 rows composed to show about ten.What changed
Vertical
:scrollrenders throughMobLazyList(LazyColumn) when its content can be lazified, and keeps the eagerColumn+verticalScrollotherwise.Flattening. Mob screens are written
scroll > column > rows, so a scroll node usually has exactly one child; lazifying its direct children buys nothing because the column underneath still composes every row. When the sole child is a column whose own props are layout-neutral, its children become the items. Onlyfill_width/fill_heightcount as neutral — padding, background, align, or anidthe harness addresses would be silently dropped, so those keep the eager path.Bounded height. A lazy container must be measured with a bounded main axis.
Columnonly measures a child against the remaining space when that child is weighted, sofill_heightalone left it unbounded.verticalScrolltolerates that;LazyColumndoes not — it composes zero items and renders an empty screen. Afill_heightscroll child now getsModifier.weight(1f)from its parent column.MobLazyListis reused rather than a freshLazyColumnso:scrollinherits the list-state hoisting already there (rememberLazyListStateresets to 0 on every BEAM re-render).Results
Main-thread frame cost, p50 / max:
The shape matters more than the percentage: lazy cost is flat in list length (83/81/92 ms at 50/200/500) while eager grows. At 50 rows lazy is slightly worse — expected, since the list nearly fits on screen and
LazyColumnhas setup cost. At 500 rows the eager p50 is unreliable (under backpressure the app draws less often, so fewer heavy frames are sampled); its max of 308 ms against 111 ms is the honest comparison.What this is not
It does not make Compose skip anything. Skipping is separately broken — a composable with a literally constant argument still recomposes every frame in this composition, which is why
@ImmutableonMobNodechanged nothing. Laziness works by composing fewer nodes, not by reusing unchanged ones. Recorded under MOB-127.Verification
Pushed with
--no-verifyTwo tests fail on this branch:
--python project compiles cleanlyandliveview_generate/3 generates local dep paths when --local flag set. Both fail identically onmasterwith this commit stashed — they are pre-existing and unrelated to this change (327/329 either way). Flagging rather than hiding it.Found on the way
MOB-136 — on Android a
text_fieldin a row under an unbounded-height container blows the row height up to ~500 px, so its siblings are centred off screen. This affects the ordinary eagerscroll > column > rowsidiom today, not just the lazy path, and iOS renders the identical tree correctly. Not fixed here.