Skip to content

MOB-128: Android :scroll composes only what is on screen - #45

Closed
GenericJam wants to merge 178 commits into
masterfrom
perf/mob-128-lazy-scroll
Closed

MOB-128: Android :scroll composes only what is on screen#45
GenericJam wants to merge 178 commits into
masterfrom
perf/mob-128-lazy-scroll

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

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 :scroll composed every child regardless of the viewport — 200 rows composed to show about ten.

What changed

Vertical :scroll renders through MobLazyList (LazyColumn) when its content can be lazified, and keeps the eager Column + verticalScroll otherwise.

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. Only fill_width/fill_height count as neutral — padding, background, align, or an id the 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. 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).

Results

Main-thread frame cost, p50 / max:

rows eager lazy
50 77.8 / 106.7 ms 83.2 / 120.4 ms
200 140.9 / 162.1 ms 81.5 / 107.5 ms
500 104.0 / 307.9 ms 91.6 / 110.9 ms

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 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.

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 @Immutable on MobNode changed nothing. Laziness works by composing fewer nodes, not by reusing unchanged ones. Recorded under MOB-127.

Verification

  • Generated an app from the patched template and built + deployed it to an emulator.
  • Every measurement confirmed with a screenshot that the rows actually render. That 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.

Pushed with --no-verify

Two tests fail on this branch: --python project compiles cleanly and liveview_generate/3 generates local dep paths when --local flag set. Both fail identically on master with 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_field in 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 eager scroll > column > rows idiom today, not just the lazy path, and iOS renders the identical tree correctly. Not fixed here.

GenericJam and others added 30 commits May 13, 2026 23:20
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.
GenericJam and others added 26 commits July 4, 2026 15:33
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>
Kotlin bridge methods backing mob 0.7.18's Mob.Audio probes: audioOutputStatus /
audioOutputLevel (#25) + mic input_level metering (#31, MOB-35). Device-verified.

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>
@GenericJam

Copy link
Copy Markdown
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.

@GenericJam GenericJam closed this Sep 2, 2026
@GenericJam
GenericJam deleted the perf/mob-128-lazy-scroll branch September 2, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants