Skip to content

docs: align screen lifecycle docs with single-GenServer navigation - #76

Draft
minibikini wants to merge 293 commits into
GenericJam:masterfrom
minibikini:fix/doc-lifecycle-single-genserver
Draft

docs: align screen lifecycle docs with single-GenServer navigation#76
minibikini wants to merge 293 commits into
GenericJam:masterfrom
minibikini:fix/doc-lifecycle-single-genserver

Conversation

@minibikini

Copy link
Copy Markdown

The docs describe a per-screen-process model that the shipped implementation doesn't have, and terminate/2 semantics that never fire the way they're described.

What the code actually does (verified against lib/mob/screen.ex):

  • One Mob.Screen GenServer owns the whole navigation stack. The active screen is a {module, socket} pair in its state.
  • push mounts the next module in that same process and stores {module, socket} in nav_history.
  • pop / pop_to / pop_to_root / reset restore a snapshot from nav_history — they start no process and call no lifecycle callback.
  • terminate/2 runs only when the whole Mob.Screen GenServer stops (app exit, crash, shutdown), and delegates to the module active at that time only.

The native side confirms this is intentional: mob_handle_back (iOS mob_nif.m, Android mob_nif.zig) looks up a single registered :mob_screen process, and renderer.ex calls set_root/1 once per render. There is no per-screen process anywhere.

Docs fixed in this PR

  • guides/screen_lifecycle.md — "Each screen in the navigation stack is a separate, supervised process" and the "popped → terminate/2" diagram step.
  • guides/navigation.md — "Passing data on pop" claimed popping restores a still-running per-screen process.
  • lib/mob/screen.ex @moduledoc — claimed one supervised GenServer per screen with per-screen crash isolation.
  • PLAN.md — "terminate/2 is called on every screen process exit" used for crash reporting.

No runtime code changes. Formatting + mix credo --strict are clean.

This is documentation-only. If a per-screen actor model is actually the intended direction, this PR highlights exactly where the shipped implementation and the docs diverge, and it would be worth deciding which way to take it before md docs keep promising isolate process-per-screen behaviour that doesn't exist.

GenericJam and others added 30 commits May 13, 2026 23:25
…n iPhone

The C and Rustler scaffolds share the same gap: scaffolded source
files exist but aren't auto-wired into the iOS/Android build
templates. Verified end-to-end on iPhone:

  - C demo: result ~c"Hello from C!" (committed earlier as d87bbda)
  - Rust demo: result "Hello from Rust!" (verified this session)

Rustler is harder because of three extra moving parts vs C:
  1. Cargo crate-type defaults to cdylib only — need to add staticlib
     for the iOS device path (now fixed in mob.add_nif d75b64c)
  2. Cross-compile target — `rustup target add aarch64-apple-ios`
     is a one-time prerequisite the scaffold doesn't run or check
  3. mix mob.deploy --native doesn't invoke `cargo rustc --target
     aarch64-apple-ios --crate-type staticlib`
  4. The resulting .a needs hand-adding to addLink in build_device.zig
  5. Rustler ≤0.36 hardcodes `nif_init` (no per-crate symbol) — also
     fixed by the scaffold pin bump to 0.37 in d75b64c

Steps 1 and 5 are now scaffold-side wins. Steps 2, 3, 4 still need
build-template work in mob_new.

Zigler on macOS 26 is still blocked upstream (issue GenericJam#15) — Zig 0.15
stdlib references absent macOS 26 SDK symbols. Linux and older
macOS users can verify zigler --demo end-to-end following the same
pattern as C/Rust.
…llow-up

iOS device + sim now auto-wire `c_src/*.c` and
`native/<name>/Cargo.toml` declared in `mob.exs :static_nifs`. End-to-end
verified on physical iPhone with `--demo` scaffolds for both C and
Rust — zero hand-editing of `build_device.zig`.

The companion landings:
  - mob_dev 8c22821 — build pipeline reads :static_nifs, cross-compiles
    Rust, passes -Dproject_{root,c_nifs,rust_libs} to zig
  - mob_new  be2ad35 — build_device.zig.eex + build.zig.eex consume
    those flags, emit addCObject per C NIF + addArg per Rust .a

Android auto-wiring (CMakeLists.txt reading :static_nifs) still to do —
filed as a follow-up. The iOS work establishes the pattern.
… still pending

Forked Zigler to github.com/GenericJam/zigler `zig-016-port`,
ported priv/beam/ to Zig 0.16 stdlib (5 files, ~50 net lines).
Host-dev `mix mob.add_nif --type zigler --demo` now compiles +
runs on macOS 26.4 — `iex> TestMigration.Nifs.GreetZig.greet()`
returns "Hello from Zig!".

mob_dev af9f732 points the scaffold at the fork.

iPhone deploy still blocked: Zigler 0.15.x's builder has no
target/crate-type knobs (emits a host-only dylib). Either feature-
add support upstream, or bypass Zigler's build for on-device.
Filed under the same issue as the next-step.
…r documented

Fork commits 2f17e63 (nif_linkage + nif_init_alias build options)
+ mob_dev 2c405c5 (cross-compile + .a wiring) lay the foundation
for iOS-device Zigler.

The one remaining piece is upstream: Zigler's cImport-dependent
modules (erl_nif) hardcode host Erlang include paths and don't
accept an isysroot argument. When zig build cross-compiles for
aarch64-ios-none, `cImport(erl_nif.h)` transitively requires
`sys/types.h` which lives in iOS SDK headers that Zig can't find
without `-isysroot $iPhoneOS_SDK_path`.

This is upstream-shaped (the author's planned 0.16 work is the
natural place). We'll pick up their fix or contribute the isysroot
patch then.
… end-to-end)

Two more fork patches landed:
  - apple_sdkroot build option (erl_nif gets SDK include path)
  - module.zig panic selection (no_panic for static, simple_panic
    otherwise — avoids dyld refs from SelfInfo)

mob_dev passes `xcrun --show-sdk-path` to the zig build via
-Dapple_sdkroot=. With both fork patches + the previous linkage
+ alias work, `mix mob.add_nif --type zigler --demo --yes`
followed by `mix mob.deploy --native --ios-device` now works
end-to-end on a real iPhone:

  iex> Mob.Test.tap(node, :run); Mob.Test.assigns(node).result
  "Hello from Zig!"

The fork (github.com/GenericJam/zigler branch zig-016-port) holds
all of the contributions. When Isaac's upstream 0.16 lands, the
patches in our fork drop in cleanly — they're all bounded to
priv/beam/ (the 0.16 stdlib port) and lib/zig/templates/
(the four build options + panic conditional).
Mirrors what we just did for iOS in GenericJam#18 + GenericJam#15:
  - Auto-wire c_src/*.c into Android's build
  - Cross-compile Rust + Zig NIFs to aarch64-linux-android
  - Link the resulting .a files into the Android .so
  - (If needed) Zigler fork gets an android_sdkroot companion
    to apple_sdkroot

mob_dev's cross_compile_rust_nifs and cross_compile_zig_nifs
already know about :android — they just aren't invoked from the
Android build path. Hooking them in is the main change; the
templates (mob_new) consume the resulting -D flags.

Scope guardrails: arm64 only first; 32-bit (armeabi-v7a) is a
follow-up. Verification target is the moto e physical device
or sdk_gphone64_arm64 emulator.

Captured while iOS context was fresh so the next agent has a
concrete plan rather than re-derived guesswork.
nif_future.md item GenericJam#4 now reflects what landed:
`copy_ios_safe_project_python_wheels/2` + `wheel_has_native_extension?/1`
in mob_dev. Merge committed as mob_dev d116c2d after pulling from
the agent's branch in /Users/kevin/code/pigeon/deps/mob_dev.

test/mob/nif_stub_test.exs: formatter wrap on the regex line — `mix
format` reflow, no semantic change.
Extends the reference snapshot (matches what
MobDev.StaticNifs.generate(:ios, _, format: :zig) emits today) so apps
using mob's bundled driver_tab — i.e. haven't run mix mob.regen_driver_tab —
get the same multi-guard table layout that the generator produces.

Both flags come from build_options (threaded in via b.addOptions in
ios/build_device.zig.eex). The four-branch chain (sqlite + emlx, emlx
only, sqlite only, neither) selects the right subset of guarded NIFs
at comptime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same wire-up as mob_dev: ExSlop runs as a Credo check via the
existing \`mix credo --strict\` run. Recommended bundle of 30
checks (blanket rescue, narrator docs, redundant Enum chains,
N+1 queries, etc).

First run on mob surfaces 5 real findings:
- 2× blanket \`rescue\` in Mob.Test and Mob.Device.maybe_set_dispatcher
- 1× Enum.reduce(%{}, ..., Map.put/3) → Map.new/for-into-%{}
- 1× identity \`case\` in test
- 1× narrator-style moduledoc on Mob.Screen

Not fixing them here. The check is the wire-up; the fixes are
separate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pre-commit checklist already runs \`mix credo --strict\` — flagging
that ExSlop is in the mix now so agents know what's being checked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Mob.Registry.build_initial: \`Enum.reduce(%{}, ..., Map.put/3)\` →
  \`Map.new/2\`. Equivalent, more idiomatic.
- test/mob/device_test.exs: drop identity \`case\` wrapping
  \`GenServer.start_link/3\` — every clause returned what it matched.

717 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ExSlop catches the AI anti-patterns post-hoc at \`mix credo --strict\`,
which costs an agent round-trip per write. Distill the 30 recommended
checks into a write-time reference: rescue/error patterns, DB query
shape (filter in SQL, no N+1), map normalization, the right Enum/list
idiom for each case, \`with\` shape, string idioms, path lookup,
comment style, and basic code shape (no Kernel shadowing, no param
rebinding, etc).

Includes a periodic-check note: ex_slop and credence both ship new
rules regularly; ~70 Credence rules aren't ported to ExSlop yet. Skim
their changelogs occasionally and update this list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OTP 29.0 final shipped today (rc3 → final is a small delta).
1.20.0-rc.5 is the matching elixir build (otp-29).

- .tool-versions: erlang 29.0, elixir 1.20.0-rc.5-otp-29
- crypto_plan.md: drop the stale OTP source-tree commit pin; track maint-29

Verified mob compiles + 744 tests pass under the new toolchain.

Bundled OTP tarballs (\`@otp_hash "7721ab74"\` in mob_dev) still contain
rc3 — rebuild is a separate workstream (cross-compile each platform,
upload to GitHub, bump @otp_hash + bundled_versions manifest).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picks up @HeroesLament's `Mob.VendorUsb` work (mob#5) and adapts the
Android JNI bridge to the all-Zig NIF surface that landed in Phase 6b
iter 3d (commit b091e67 — "delete mob_nif.c, all-Zig NIF surface").
The Elixir / Erlang / iOS sides come over verbatim; only the C-side
piece needed porting because mob_nif.c no longer exists.

## What's in the runtime side

### `android/jni/mob_nif.zig`

* 7 new NIFs mirroring the `Mob.VendorUsb` Elixir API:
  - `vendor_usb_list_devices/1`         — JSON filter → Bridge static method
  - `vendor_usb_request_permission/1`   — device ref → Bridge
  - `vendor_usb_open/1`                 — open opts JSON → Bridge
  - `vendor_usb_bulk_write/3`           — session + iolist + timeout, dirty IO
  - `vendor_usb_start_reading/2`        — session + chunk size
  - `vendor_usb_stop_reading/1`         — session
  - `vendor_usb_close/1`                — session
* 6 `mob_deliver_vendor_usb_*` exports the JNI thunks (in mob_new#2's
  generated `beam_jni.c`) invoke when Kotlin emits USB events. Each
  builds a 5-tuple `{:peripheral, :vendor_usb, tag, session, payload}`
  and posts it to the originating pid. session=-1 → :nil atom.
* `BridgeMethods` struct gains 7 jmethodID slots; nif_load caches them
  via `cacheOptional`. The vendor_usb NIFs short-circuit with
  `{:peripheral, :vendor_usb, :error, nil, :unsupported}` when the
  matching methodID is null — mirrors the iOS stub behaviour and
  keeps apps generated from an older `mob_new` template (no Kotlin
  vendor_usb block yet) booting cleanly. Once mob_new#2 lands, the
  jmethodIDs resolve and the NIFs route to MobBridge normally.

### `android/jni/mob_zig.zig`

* New `JByte` / `JByteArray` / `JSize` type aliases.
* Typed `NewByteArray` + `SetByteArrayRegion` slots in the
  `JNINativeInterface` vtable (with intermediate opaque slots for
  GetDoubleArrayRegion + SetBooleanArrayRegion so the layout still
  matches jni.h byte-for-byte). Added `newByteArray` /
  `setByteArrayRegion` wrapper inlines. nif_vendor_usb_bulk_write
  uses both to hand a fresh `byte[]` to Kotlin without re-resolving
  the BEAM binary across the JNI boundary.

### `android/jni/mob_beam.h`

* 13 lines of extern decls for the 6 `mob_deliver_vendor_usb_*`
  exports, so beam_jni.c (downstream-project glue emitted by
  mob_new#2's template) can include this header and resolve the
  symbols at link time.

## Clean-merge pieces from mob#5 (no port required)

* `lib/mob/vendor_usb.ex` — the Mob.VendorUsb public module (334
  lines, byte-for-byte).
* `test/mob/vendor_usb_test.exs` — 10 tests covering
  normalize_message/1 + passthrough cases (10/10 pass).
* `lib/mob/screen.ex` — one `handle_info({:peripheral, :vendor_usb,
  …})` clause routing through `Mob.VendorUsb.normalize_message/1`
  before the user's `handle_info/2` sees it.
* `src/mob_nif.erl` — 7 entries each added to `-export([...])` and
  `-nifs([...])`, plus 7 `nif_error(not_loaded)` stub clauses.
* `ios/mob_nif.m` — 7 iOS stubs + table entries. All emit
  `{:peripheral, :vendor_usb, :error, nil, :unsupported}` and return
  `:ok`. iOS exposes no public USB-host API.

## Verified

`mix test` clean: 27 doctests, 727 tests, 0 failures (including the
10 new normalize_message tests).

End-to-end build on a moto g power (2021) arm64 device, with
`mob_dir` pointed at this worktree:

  mix mob.deploy --native --device ZY22DP6HFL

builds, installs, and the BEAM boots with the new NIF table. logcat
shows:

    nif_load: vendor_usb_list_devices not found (optional)
    nif_load: vendor_usb_request_permission not found (optional)
    nif_load: vendor_usb_open not found (optional)
    nif_load: vendor_usb_bulk_write not found (optional)
    nif_load: vendor_usb_start_reading not found (optional)
    nif_load: vendor_usb_stop_reading not found (optional)
    nif_load: vendor_usb_close not found (optional)
    Mob NIF loaded (Compose backend)

— exactly the expected behaviour pending mob_new#2 (the matching
MobBridge.kt vendor_usb block).

## Out of scope (per the issue's hardware caveat)

Full lifecycle verification (`list_devices` → `request_permission` →
`open` → `start_reading` + `bulk_write` → `stop_reading` → `close`)
needs the AtomVM ESP32 + Taixin TX-AH HaLow modem rig
@HeroesLament originally tested against. Their PR description calls
out two bug classes only reproducible with the real hardware in the
loop (nil → JSON "nil" string; getInt JSONException → BEAM crash).
Ping @HeroesLament to drive that pass once mob_new#2 lands.

## Coordination

This commit makes sense to merge **paired with mob_new#2** (the
Kotlin / manifest / JNI-thunk templates). Either order works at build
time — the runtime here is forward-compatible with both presence and
absence of the matching MobBridge.kt — but the user-visible feature
only works end-to-end when both are in place.
Adds a top-level mob guide that summarises the two NIF-adjacent
commands, the decision rule between them ("am I naming this thing?"
→ add_nif, "is it a pre-named feature?" → enable), and the file list
each one writes.

Deliberately a summary, not a duplicate. The detailed contract —
per-backend mechanics, what each upstream library does normally vs.
what Mob changes for static linking, where the bundled CPython runtime
comes from on each platform (BeeWare iOS, Chaquopy Android), which
workarounds are transient and what they need from upstream to drop —
lives in mob_dev/guides/nifs.md. This guide links there throughout.

The split matches the project structure: mob is the runtime users
write apps against; mob_dev is the build/dev tooling. Users and their
agents reading mob's docs see the summary and one click takes them to
the receipts.
Adds a one-call helper that flips BEAM's lookup chain to
`[:file, :dns]` and seeds fallback nameservers (Google + Cloudflare
by default). After it runs, `:inet.getaddr/2` resolves via raw
UDP/TCP DNS queries performed from inside BEAM by `inet_res` —
no port program, no `execve`, so iOS's sandbox doesn't block it.
The whole `:inet`-mediated HTTP stack (Req / Finch / Mint /
HTTPoison / Tesla / :httpc / gen_tcp:connect/3) then works
without per-host setup.

This is the cleaner default: most apps talk to a known set of
public-internet hosts on consumer Wi-Fi or cellular, and that's
exactly the case where pure-BEAM DNS suffices.

`Mob.DNS.resolve/1` / `preresolve/1` stay around for the cases
where Apple-resolver semantics genuinely matter — VPN-pushed DNS
for internal hostnames, `.local` / mDNS, search-domain expansion
(single-label hostnames), captive portals, OS-level encrypted
DNS. Because `:file` is first in the lookup chain,
manually-resolved entries always win over the `:dns` fallback,
so the two paths compose without conflict.

Hat-tip to the user (reading `kernel/src/inet*` for fun) who
asked whether configuring `inet_db` directly was enough; their
hypothesis was correct for the common case. The right answer
turned out to be "do both, default to the simple one."

The guide gets a trade-off table comparing the two paths
(captive portals, VPN, mDNS, search domains, TTL refresh, cost
per lookup, etc.) so the next reader doesn't have to derive it.

Tests: six new cases for `configure_pure_beam/1` covering the
default-nameservers seed, custom nameservers, the
`nameservers: []` lookup-only mode, idempotency, and the
file-first-in-chain composition guarantee.

The setup block now snapshots and restores `:inet_db`'s
nameserver list too, so the new tests don't leak Google +
Cloudflare into sibling tests.
…nest

Closes the "permissions trap" surfaced by an end user trying to wire
Mob.Location into a screen and watching the iOS dialog never appear.
Two pieces:

## 1. `guides/permissions.md` — single source of truth

New extras-guide that consolidates everything OS-permission-adjacent
into one place that all the per-capability moduledocs and
`device_capabilities.md` now point at:

  * Per-capability table: what `Mob.Permissions` capability maps to
    which `Info.plist` key on iOS and which `AndroidManifest.xml`
    `uses-permission` line on Android. Plus the operations that
    need a plist key WITHOUT going through `Mob.Permissions.request/2`
    (storage_save_to_photo_library, camera preview, …).
  * "What `mix mob.new` ships by default" section — the template
    covers camera + microphone on iOS and most capabilities on
    Android, so users hit the missing-plist-key trap when they
    *add* a feature post-`mob.new`. The guide names the most-common
    missing keys (location, photo library, photo library add) and
    pastes the snippet to drop into Info.plist.
  * iOS-specific notes section covering the
    not-determined-→-no-plist-key silent failure, the previously-
    undocumented "what counts as :granted" for `:photo_library`
    (Limited counts), notifications.
  * Android-specific notes: foreground-vs-background location,
    notifications on API ≤32 (no permission needed), storage and
    photos on API 33+ (READ_MEDIA_* replaces READ_EXTERNAL_STORAGE).
  * "Re-requesting after denial" — OS won't re-prompt; need to send
    the user to Settings.
  * "Diagnosing a stuck request" 5-step checklist for the exact
    failure mode that motivated this guide.
  * Cross-platform pattern at the end so a reader doesn't have to
    leave the guide for working code.

`guides/device_capabilities.md`'s `## Permissions` blockquote and
the moduledocs for `Mob.Permissions`, `Mob.Location`, `Mob.Camera`,
`Mob.Audio`, `Mob.Photos`, `Mob.Notify` all now point readers here
on the first failure-mode they're likely to hit.

## 2. Make iOS `:location` honest

`nif_request_permission("location")` no longer synthesises
`{:permission, :location, :granted}` unconditionally. Instead it
drives a dedicated `CLLocationManager` + `MobLocationPermissionDelegate`
through `requestWhenInUseAuthorization`, reads
`locationManagerDidChangeAuthorization:`, and reports the user's
real choice (`AuthorizedWhenInUse|Always` → `:granted`,
`Denied|Restricted` → `:denied`, `NotDetermined` → keep waiting).

Knock-on improvements:

  * `MobLocationDelegate` (the existing updates-delivery class) also
    learns `locationManagerDidChangeAuthorization:` and dispatches
    `{:location, :error, :permission_denied}` when the user revokes
    mid-session or denies a `Mob.Location.get_once/1` that skipped
    the explicit `request/2` step. Before this commit, that path
    just stopped delivering fix events with no diagnostic — screens
    sat at "waiting for fix…" indefinitely.
  * `Mob.Location` moduledoc now documents both `:permission_denied`
    and `:unavailable` as expected `{:location, :error, reason}`
    atoms.

The new delegate is iOS 14+ only (`locationManagerDidChangeAuthorization:`,
not the deprecated `didChangeAuthorizationStatus:`); Mob's
minimum-deployment is iOS 17, so this is well within scope.

## Verified

`mix test` clean (733 tests, 0 failures, including the 10
`Mob.VendorUsbTest` we already had landing). `mix docs` emits no
warnings on the new `guides/permissions.md`. Two existing call sites
that user-screen-grade behaviour (`NifRace.LocationScreen` in the
demo, `Mob.Permissions.request(:location)` in any project) work
without changes — the new flow is a strict drop-in for the prior
fake-grant.
New: start_frame_stream/2 + stop_frame_stream/1 deliver per-frame
{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}
messages to the calling process. Defaults to 640×640 rgb_f32 for direct
hand-off to Nx tensors; opts let callers pick width/height/format/facing
and a software throttle (throttle_ms).

iOS implementation uses one shared AVCaptureSession (g_preview_session)
for both preview and frame stream. iOS allows only one session per
physical camera, so previous two-session design silently dropped frames.
A serial config queue (g_camera_queue) serializes input/output
attachment so start_preview + start_frame_stream compose in any order.
vImageScale_ARGB8888 handles resize + center-crop on the capture queue
before the BGRA→RGB f32 conversion. Frame bytes flow over enif_send to
the caller pid; the delegate is held in g_frame_delegate (Apple's API
does not retain it).

Android: stub returns :unsupported so callers don't crash. Live frames
on Android will land in a follow-up.

Tests: frame_stream_opts/1 covers defaults, overrides, string-keys, and
JSON encoding (8 tests, all green).
Adds a Bluetooth Classic API for Android. iOS returns :unsupported
(Classic profiles need MFi). Companion to mob_new#3 (Kotlin / manifest
/ JNI templates).

## Elixir surface

  - lib/mob/bt.ex             — Mob.Bt (discovery, pairing, disconnect)
  - lib/mob/bt/hfp.ex         — HFP profile (connect, SCO audio, vendor AT)
  - lib/mob/bt/spp.ex         — SPP profile (RFCOMM client)
  - lib/mob/bt/hid.ex         — HID profile (raw input reports)

Vendor AT subscribe takes a caller-specified `:company_ids` keyword so
apps can target specific BT SIG company codes per call (Hytera 313,
Apple 76, Qualcomm 10, Plantronics 1117, etc.). Empty list = no events.

## NIF surface

16 NIFs in android/jni/mob_nif.zig:

  - bt_list_paired/0, bt_start_discovery/0, bt_cancel_discovery/0
  - bt_pair/1, bt_unpair/1, bt_disconnect/1
  - bt_hfp_connect/1, bt_hfp_subscribe_vendor_at/2,
    bt_hfp_send_vendor_at/3, bt_hfp_start_sco/1, bt_hfp_stop_sco/1,
    bt_hfp_send_audio/2 (DIRTY_IO)
  - bt_spp_connect/1, bt_spp_write/2 (DIRTY_IO)
  - bt_hid_connect/1, bt_hid_subscribe_raw/1

33 mob_deliver_bt_* exports the JNI thunks (in mob_new#3's generated
beam_jni.c) invoke when Kotlin emits BT events. Each builds a 4-tuple
`{:bt | :bt_hfp | :bt_spp | :bt_hid, tag, session_or_nil, payload}`
and posts it to the originating pid.

BridgeMethods gains 16 jmethodID slots; nif_load caches them via
cacheOptional. The bt_* NIFs short-circuit with
`{:bt, :error, nil, %{reason: :unsupported}}` when the matching
methodID is null, so apps from an older mob_new template still boot.

BT atom cache (~30 atoms + map keys) is initialised once at nif_load.
The paired-list streaming accumulator is a 16-slot fixed-size table
protected by its own mutex, holding up to 128 entries per concurrent
caller so multiple processes can list paired devices simultaneously.

## Verified

  - mix test: 760 passed, 0 failed
  - zig ast-check android/jni/mob_nif.zig exits 0
Mob.Camera: live frame stream API + shared AVCaptureSession
iOS's camera sensor captures in landscape-right by default. With the
phone held in portrait, both AVCaptureVideoPreviewLayer and the new
AVCaptureVideoDataOutput delivered sideways frames — invisible to the
user when only the preview was used, but devastating once we started
feeding pixels to an ML model trained on upright COCO images. A jar
held vertically in the UI arrived at YOLO as a horizontal bar and got
classified as "laptop" or "cell phone" at low confidence.

Pin both the preview and the frame stream to 90° (videoRotationAngle on
iOS 17+, videoOrientation = .portrait on older builds). With this in
place, the same jar lands as "cup 96%" — high enough that the demo no
longer needs the tuned-down confidence threshold to surface anything.

What you see on the preview is what the model sees, and detection
boxes now align with their objects.
…tion

camera: rotate session to portrait so YOLO sees an upright scene
Three fixups on top of HeroesLament's Mob.Bt PR:

* Mob.Bt.Hfp.subscribe_vendor_at/3 was using Jason.encode!, but :jason
  is not a runtime dep (only pulled in transitively by credo for dev/
  test). Replaced with the same `:json.encode |> IO.iodata_to_binary`
  pattern used elsewhere in the module — would have crashed on first
  call from a deployed app.

* The encode_device/1 helper was duplicated in Mob.Bt, Mob.Bt.Hfp, and
  Mob.Bt.Hid (and inlined in Mob.Bt.Spp.connect/3). Promoted the
  Mob.Bt version to `@doc false` and have the sub-profiles delegate to
  it. Mob.Bt.Spp.encode_connect/2 and Mob.Bt.Hfp.encode_vendor_at_opts/1
  extracted in the same shape — public-but-undocumented so the test
  suite can drive them directly without going through the NIF.

* New tests: 14 cases covering the JSON encoders against representative
  device shapes — minimal/full devices, nil-value drop, optional fields
  preserved, pair with/without PIN, SPP UUID + secure defaults, custom
  UUID, insecure RFCOMM, vendor AT company-id list shape. The native
  surface (Zig + iOS stubs) stays "tested manually on device" per the
  CLAUDE.md convention.

* clang-format pass on ios/mob_nif.m + android/jni/mob_beam.h to clear
  pre-commit format violations on the new BT code.
… Classic peripheral

Adds a Bluetooth Classic API for Android covering HFP (audio + vendor
AT commands), SPP (RFCOMM byte streams), and HID (input reports). iOS
returns :unsupported (Classic profiles need MFi).

Companion to mob_new#4 (BT codegen templates). Either order works at
build time; runtime short-circuits to :unsupported when the matching
MobBridge methodID isn't present.

Includes follow-on review fixes: drops a Jason.encode! call that
would have failed at runtime (Jason is dev-only), dedupes the
encode_device helper across modules, and adds 14 unit tests covering
the JSON encoder surface.
GenericJam and others added 26 commits June 30, 2026 22:36
The section was renamed to 'Defining your own components', which drops the
old auto-generated anchor. PR GenericJam#56 links to #pure-elixir-composite-components
from the @assigns note; add an explicit anchor so that link resolves
regardless of merge order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…guard

MOB-5: ~MOB @foo raises a clear error when assigns is not in scope
…ring-examples

docs: worked component-authoring examples in components guide
MOB-5: ~MOB raises a helpful CompileError when @foo is used without an
`assigns` in scope (guard via Macro.Env.has_var?, mirroring ~H), instead
of a cryptic "undefined variable assigns". Plus worked component-authoring
examples in the Components guide. (GenericJam#56, GenericJam#57)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-Composite (GenericJam#58)

Follow-up to the 0.7.12 component-authoring guide, addressing the two things
issue GenericJam#53 actually got stuck on:

- The '~MOB: <Tag> is not in the Mob tag whitelist — pass-through' warning is
  expected for any registered composite (registration is runtime; the sigil
  macro can't see it at compile time). Say so, so it reads as informational
  rather than 'it doesn't work'.
- Disambiguate Mob.Component (existing native-view behaviour, render/1 returns
  a native props map) from Mob.Composite (pure-Elixir tag expanders, expand/3
  returns a ~MOB tree). The reporter reached for use Mob.Component; steer that
  instinct to Composite. Also reword the 'sub-component event isolation' note so
  it no longer calls the planned feature 'Mob.Component' (that name is taken).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e docs

Doc-only patch so the components-guide clarifications from GenericJam#58 reach hexdocs.pm
(HexDocs rebuilds only from a published Hex release). Addresses the two
sticking points in GenericJam#53: the whitelist warning is expected for a registered
composite, and Mob.Component (native-view behaviour) is distinct from
Mob.Composite (pure-Elixir tag expanders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-6: Mob.Motion magnetometer / compass (Elixir + iOS; Android code)

Add :magnetometer to Mob.Motion — the {:motion, _} map gains `mag` (µT) + a fused
`heading` (deg from magnetic north; nil when unavailable). See
decisions/2026-07-02-magnetometer-compass.md and COMPATIBILITY notes.

- Elixir (motion.ex): :magnetometer sensor + the mag/heading contract + docs.
- iOS (mob_nif.m): parse the sensor list; when :magnetometer is requested +
  available, use the XMagneticNorthZVertical reference frame → calibrated field +
  heading on the device-motion stream. Plain accel/gyro path unchanged.
- Android (zig mob_deliver_motion_mag + mob_beam.h prototype): new 5-key delivery
  fn; the Kotlin half is in the mob_new template (paired PR). Keeps the existing
  mob_deliver_motion path byte-identical.

VERIFY STATUS: Elixir + iOS host-checked; the Android Kotlin compiled cleanly on a
real device build. Full zig/link + on-hardware compass read is NOT yet verified —
the only wired test app (mob_test) is a pre-Mix→zig-migration fossil (no build.zig)
that can't build against current mob, unrelated to this change. Verify on a current
app (fresh `mix mob.new` or migrated mob_test) with a magnetometer device
(moto g 2021 has one) before release. Android magnetometer is registered whenever
the hardware is present (v1); opt-in threading is a noted follow-up in the ADR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-6: test Mob.Motion opts parsing incl. magnetometer

The feature shipped without any Mob.Motion test (the module had none).
Extract start/2's pure kernel as parse_opts/1 — resolves the sensor list +
interval, applying defaults — so it's unit-testable without a loaded NIF,
then cover the default, magnetometer, magnetometer-only, custom-interval, and
order-preservation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-6: make the :magnetometer contract stable + Android opt-in

Review found the docstring promised two things the code didn't do:
1. "heading is nil without a magnetometer" — actually the key was ABSENT
   (3-key fallback), a KeyError trap for exactly the compass app this enables.
2. "mag/heading only when you request :magnetometer" — true on iOS, but Android
   never received the sensor list, so it registered the magnetometer whenever
   the hardware existed regardless of the request (surprise 5-key maps + battery
   cost for accel/gyro-only consumers like the tilt-follow eyes).

Fix — make the map shape a function of the request, uniformly:
- Requested :magnetometer => mag + heading keys ALWAYS present, each nil when
  there's no reading (no hardware, or heading not yet fused). Stable to match on.
- Not requested => plain 3-key accel/gyro stream, byte-identical to before.

Mechanics (no FFI arity change):
- Android sensor set is plumbed through the existing JNI string: nif_motion_start
  encodes "<interval>" or "<interval>,magnetometer"; Kotlin registers the
  magnetometer + rotation-vector only when requested (mob_new PR).
- mob_deliver_motion_mag maps a NaN mag component -> mag: nil (alongside the
  existing heading < 0 -> nil), so "requested but no hardware" rides the 5-key
  delivery with sentinels. Adds enif_get_list_cell to scan the sensor list.
- iOS builds the 5-key map whenever want_mag, filling nil/nil when the
  magnetic-north reference frame isn't available instead of dropping to 3-key.

Device-verified all three paths: real values (moto g + iPhone SE), opt-in 3-key
(moto g + iPhone SE), and nil/nil graceful degradation on a no-magnetometer
Android emulator. Decision: decisions/2026-07-04-magnetometer-stable-key-contract.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-6: mark 2026-07-02 ADR partially superseded

The 2026-07-04 stable-key-contract ADR changed the Android activation
trigger (now opt-in) and the map-shape/nil-key contract. Flag the old
ADR's Status so a reader landing there isn't misled by the now-stale
'Android registers whenever hardware present (v1)' decision, while
keeping the parts still in force (magnetic-north scope, additive keys,
delivery via mob_deliver_motion_mag).

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 MOB-6 (GenericJam#59): request :magnetometer for mag + fused heading, with a
stable per-request key contract (keys present exactly when requested, nil
when no reading) identical across iOS and Android. Device-verified on
moto g + iPhone SE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation-lock/BLE (GenericJam#60)

Four rows were stale against shipped code:

- Screen orientation lock  ❌ → ✅  (Mob.Device.lock_orientation/1 + unlock_orientation/0)
- Bluetooth Low Energy      ❌ → 🟡  (MobBluetooth.Le 0.3.0 — GATT peripheral role,
                                      advertise + notify, iOS + Android; no BLE central yet)
- Magnetometer              ❌ → ✅  (Mob.Motion :magnetometer — mag + fused heading, 0.7.14, MOB-6)
- Compass / heading         ❌ → ✅  (fused heading via same path)

Also corrected the Bluetooth Classic note (no Hid sub-module in mob_bluetooth;
only Hfp/Spp) and clarified its central/host, Android-only scope.

Swept the remaining ❌/🟡 rows against current lib/ and the MOB backlog:
all other missing capabilities still map to open MOB issues (sensors, NFC,
WiFi, brightness, keep-awake, network state, speech-recognition, etc.) —
verified still absent from core, no further changes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-15: torch / flashlight support in core (Mob.Torch)

New Mob.Torch (on/1, off/1, set/2) toggles the rear-camera torch with no
camera session and no permission, so it's a lightweight core capability
alongside Mob.Haptic rather than part of mob_camera.

- lib/mob/torch.ex — on/off API; pure state_atom/1 mapping (host-testable)
- src/mob_nif.erl — torch/1 NIF declaration + stub
- ios/mob_nif.m — nif_torch: AVCaptureDevice torchMode, hasTorch guard,
  setTorchModeOnWithLevel: at max; no-op without a torch
- android/jni/mob_nif.zig — nif_torch -> MobBridge.torch(String) via the
  cached-method seam (Kotlin half is the paired mob_new change)
- test/mob/torch_test.exs — state_atom mapping + boolean guard
- decisions/2026-07-04-torch.md — core-vs-plugin + on/off-only rationale
- guides/mobile_surface_matrix.md — Torch row

On/off only for v1 (iOS supports brightness levels, Android setTorchMode is
binary — level is a follow-up). Device verification pending (no torch on the
simulator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-15: mark torch device-verified (moto g + iPhone SE)

Both platforms physically lit the rear flash on and off — Android via
:mob_nif.torch/1 over dist, iOS via an on-device Mob.Torch.set/2 button.

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 MOB-15 (GenericJam#61): Mob.Torch.on/off/set toggles the rear-camera torch with
no capture session and no permission. Device-verified on moto g power (2021)
and iPhone SE (3rd gen). Android Kotlin bridge ships via mob_new 0.4.17+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enericJam#63)

The example used `on_tap={tap(:increment)}`, but no `tap/1` exists — `use
Mob.Screen` imports the sigil, not a `tap` helper — so pasting the getting-
started example produced a compile error (GH GenericJam#46). Use the canonical inline
tuple `on_tap={{self(), :increment}}` (matches `Mob.Sigil`'s own docstring,
screen_lifecycle.md, and device_capabilities.md), which the existing
`handle_info({:tap, :increment}, ...)` already handles. Added a clause to the
walkthrough explaining the `{self(), :increment}` → `{:tap, :increment}` wiring.

Verified: the corrected module compiles and renders a valid tree.

Closes GenericJam#46.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Jam#64)

Color props accept raw colors as 0xAARRGGBB integer literals (alpha first),
which is easy to get wrong coming from web/CSS habits — coding assistants
especially default to "#RRGGBB" strings and alpha-last. The theming guide only
showed one opaque example without spelling out the format.

Add an explicit "Raw colors are 0xAARRGGBB integers, not CSS hex strings"
section: it's an integer literal not a string, alpha is the FIRST byte (not
last like CSS #RRGGBBAA), always include the alpha byte (a 6-digit value reads
as alpha 00 = transparent), and show that the alpha byte is what makes a
translucent/frosted panel composable from primitives. Cross-link it from the
components prop-value reference.

Docs-only.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enericJam#62)

* MOB-14: network / connectivity state in Mob.Device (query + events)

Adds a cross-platform connectivity capability alongside battery/thermal:

- Mob.Device.network_state/0 -> %{online, transport, expensive}
  (transport: :wifi | :cellular | :wired | :other | :none), plus online?/0.
- New :network subscribe category delivering
  {:mob_device, :connectivity_changed, state} on change.

Wiring mirrors the existing device-state seam across all four layers:
- lib/mob/device.ex: query fns, :network category, connectivity_changed
  event + category_for/1 clause.
- src/mob_nif.erl: device_network_state/0 in -export, -nifs, stub body.
- ios/mob_nif.m: a process-lifetime NWPathMonitor caches the snapshot and
  pushes connectivity_changed; query reads the cache. Network.framework
  autolinks via -fmodules (no build change). No permission/plist key needed.
- android/jni/mob_nif.zig + mob_beam.h: cache-backed query NIF and a
  mob_send_connectivity_changed export for the beam_jni.c trampoline. The
  ConnectivityManager.NetworkCallback that drives it ships in the mob_new
  template (companion PR; needs ACCESS_NETWORK_STATE).

Tests (test/mob/device_test.exs): :network fan-out, category_for mapping,
raise-without-NIF for network_state/0 + online?/0.

Verified: full suite green (974); nif_declaration_test confirms the
Erlang/iOS/Zig tables agree; iOS device-verified on a booted simulator —
network_state/0 returns %{online: true, transport: :wifi, expensive: false}
over dist. Android query/event land with the mob_new companion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-14: harden Zig bool→atom to avoid peer-type ambiguity

Extract boolAtomName/1 with an explicit [*:0]const u8 return so each
`if (b) "true" else "false"` branch coerces unambiguously, rather than
relying on argument-position result-location coercion of two different
string-literal array lengths. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-14: fix Android transport cache dangling pointer (device-caught)

Android device verification returned transport: :"EMU=beam" (garbage).
mob_send_connectivity_changed stored the incoming JNI string pointer into
the g_net_transport global, but beam_jni.c releases that string
(ReleaseStringUTFChars) as soon as the trampoline returns — so the cached
pointer dangled and device_network_state/0 read freed memory.

Cache an int transport code instead (0 none/1 wifi/2 cellular/3 wired/
4 other) and derive the atom from a static literal via transportAtomName/1,
mirroring the iOS int-code path. Trampoline/Kotlin/header unchanged.

Verified on moto g power (2021): network_state/0 now returns
%{online: true, transport: :wifi, expensive: false}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-14: address adversarial review — dedup, online? guard, doc, test

- ios/android native: only emit connectivity_changed when the exposed
  {online,transport,expensive} snapshot actually changed (NWPathMonitor /
  onCapabilitiesChanged also fire on validation/bandwidth/constrained flips
  that don't change what we report). Cache still refreshed either way so the
  synchronous query stays current.
- online?/0: tolerate a non-map return (the natives' :nil map-build fallback)
  instead of raising BadMapError.
- network_state/0 doc: clarify `online` means a usable default path is up, not
  that the internet is reachable (captive portals report online on both
  platforms) — documents the cross-platform semantics rather than diverging.
- test: pin `:network in categories()`.

Re-verified on both targets after rebuild: network_state/0 still returns
%{online: true, transport: :wifi, expensive: false} on iOS sim + moto g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-14: expose validated (Android) + constrained (iOS) with :unavailable sentinel

Surface each platform's full connectivity signal rather than the
lowest-common-denominator. network_state/0 gains two single-platform fields;
the platform that can't answer returns the atom :unavailable (not a misleading
false or an ambiguous nil):

- validated  — Android NET_CAPABILITY_VALIDATED (real internet confirmed; false
  on a captive portal). :unavailable on iOS — NWPath has no reachability probe.
- constrained — iOS nw_path_is_constrained (Low Data Mode). :unavailable on
  Android — no per-network equivalent.

Map is now %{online, transport, expensive, validated, constrained}. iOS caches
constrained (added to the dedup set); Android caches validated and takes it as a
new mob_send_connectivity_changed arg (header + trampoline + Kotlin updated in
mob_new companion).

Device-verified both targets:
  iOS sim  => validated: :unavailable, constrained: false
  moto g   => validated: true,         constrained: :unavailable

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-14: review nits — atomic Android globals + event-shape docs

- android/jni/mob_nif.zig: g_net_* globals -> std.atomic.Value with monotonic
  load/store, matching the iOS _Atomic half (written on the JNI callback thread,
  read on a scheduler thread).
- docs: mob_beam.h connectivity_changed comment updated to the full 5-key map;
  device.ex clarifies the event `state` is the same map network_state/0 returns.

Re-verified on moto g: network_state/0 =>
%{online: true, transport: :wifi, expensive: false, validated: true, constrained: :unavailable}.

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 MOB-14 (GenericJam#62): Mob.Device.network_state/0 + online?/0 + the :network
subscribe category. %{online, transport, expensive, validated, constrained},
with :unavailable where a platform can't answer. iOS NWPathMonitor + Android
ConnectivityManager.NetworkCallback (Kotlin bridge via mob_new 0.4.18+).
Device-verified on iOS sim + moto g power (2021). Also ships doc fixes GenericJam#63/GenericJam#64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MOB-20: keep-awake / idle-timer support in Mob.Device

New Mob.Device.keep_awake(on?) prevents the screen auto-dimming/locking
while enabled — for video, reading, navigation, or any watch-without-touch
screen. No permission on either platform. Mirrors the lock_orientation seam.

- lib/mob/device.ex — keep_awake/1 (boolean toggle, returns :ok)
- src/mob_nif.erl — device_keep_awake/1 NIF decl + stub
- ios/mob_nif.m — nif_device_keep_awake: UIApplication.isIdleTimerDisabled (main thread)
- android/jni/mob_nif.zig — nif_device_keep_awake -> MobBridge.keepAwake(Int)
  via cacheOptional + null-guard (drift no-ops); Kotlin half is the mob_new change
- test/mob/device_test.exs — boolean-guard test
- decisions/2026-07-04-keep-awake.md — core-vs-plugin + write-only rationale
- guides/mobile_surface_matrix.md — Idle timer row ❌ -> ✅

Device verification pending (screen-stays-lit is observable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-20: mark keep-awake device-verified (moto g + iPhone SE)

Android: dumpsys shows fl=KEEP_SCREEN_ON toggling with keep_awake(true/false).
iOS: screen stays lit past Auto-Lock while enabled, dims when released.

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 MOB-20 (GenericJam#66): Mob.Device.keep_awake/1 prevents screen auto-dim/lock, no
permission. Device-verified both directions on moto g power (2021) + iPhone SE
(3rd gen). Android Kotlin bridge ships via mob_new 0.4.19+.

(0.7.16 was taken by the parallel connectivity release, which already folded in
the docs from GenericJam#63/GenericJam#64 — so GenericJam#65 is superseded.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st tests (GenericJam#67)

* MOB-35: Mob.Audio input-level metering — Elixir API + NIF decls + tests

The agent-facing "ears" contract: start_input_metering/1, input_level/0
(returns {rms,peak}|:silent|{:error,reason} — same shape as
MobAudioCapture.output_level/0), stop_input_metering/1. NIF functions declared
in mob_nif.erl. Pure decode_level/1 host-tested (25 pass).

Native impl (iOS AVAudioRecorder metering / Android AudioRecord bridge) and
on-device verification are the next stage — the functions raise not_loaded until
the native side registers them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-35: iOS native mic input-level metering + Android NIF stubs

iOS: AVAudioRecorder-based metering (start/level/stop) — records to a throwaway
temp file with meteringEnabled, reads averagePower/peakPower (dBFS), shares the
mic session with recording, deletes the temp file on stop. Registered in
nif_funcs[]. (An AVAudioEngine input tap would avoid the temp file — future.)

Android: zig NIF stubs (input_level -> :not_implemented; start/stop no-op) so the
NIF table stays consistent with iOS and the module loads on both; the real
AudioRecord bridge is MOB-35 stage 3.

Native builds at device deploy; clang-format + zig fmt clean. On-device acoustic
verification (mic hears speaker) on the iPhone SE is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-35: Android mic input-level metering (zig NIF)

Implements the Android side of input_level: the zig NIFs call the Kotlin
MobBridge (MediaRecorder-based mic metering) — start/stop via CallStaticVoidMethod,
input_level via CallStaticIntMethod reading MediaRecorder.getMaxAmplitude
(0..32767 → dBFS, -1 → :not_metering). Types the previously-opaque
CallStaticIntMethod JNI binding in mob_zig.zig.

Device-verified on a moto g power (2021): input_level sits at a ~-55 dBFS ambient
floor and spikes to -19/-40 dBFS when sound hits the mic. Pairs with the Kotlin
MobBridge methods (mob_new template — separate change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* MOB-35: make Android input-metering bridge methods cache-optional

cacheRequired for audio_start_input_metering/audio_input_level/
audio_stop_input_metering made nif_load return -1 (boot crash) whenever
the paired MobBridge.kt methods were absent — i.e. if core merges before
the mob_new#31 template, or on any stale-template drift (the same failure
mode as the torch desync).

Switch to cacheOptional and null-guard each NIF impl to return
:unsupported_on_platform (decoded to {:error, :unsupported_on_platform})
instead of dereferencing a null JMethodID. Decouples this PR from
mob_new#31 merge order and degrades gracefully on drift. Matches the
boot-safety pattern the sibling audio-output probes (mob#54) use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

* Add Mob.Audio output probes — verify sound is actually working

Mob can verify visual output in-process via screenshot/3, but had no
equivalent for audio. This adds two read-only probes that answer "is
sound actually coming out right now":

- Mob.Audio.output_status/0 — {volume, muted, route, other_audio}.
  Cheap, no permission. Catches the common silence causes (muted,
  volume 0, dead route). iOS AVAudioSession / Android AudioManager.
- Mob.Audio.output_level/1 — {rms_db, peak_db} | :silent | {:error, _}.
  Reads actual signal energy, the part output_status and `adb dumpsys
  audio` cannot answer. source: :mix (default) taps the global output
  mix so it sees native players that bypass Mob.Audio (e.g. a game's
  own AudioTrack); source: :mob taps Mob.Audio's own player.

Native wiring mirrors screenshot/3 + open_settings/1: -export/-nifs/stub
in mob_nif.erl, native table entries in mob_nif.zig and mob_nif.m, and
cacheOptional + null-guard for the app-owned Android bridge methods so a
drifted MobBridge.kt no-ops instead of crash-looping boot. iOS level-2
uses AVAudioPlayer metering (self-contained); Android uses a Visualizer
on session 0 (needs RECORD_AUDIO). output_level is a dirty IO NIF.

Honest asymmetry: for audio that bypasses Mob.Audio, level-2 works on
Android (global-mix tap) but not iOS (sandbox forbids it) — documented,
not papered over. Decoders are pure Elixir and unit-tested on host; the
Android Kotlin bridge methods ship in the mob_new template (separate PR).

Host: 969 mob tests pass, formatters clean. Device verification (boot +
live level reads) is the gate for the native paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Audio probes: device-verified scope — :mob own-session, :mix unsupported

Device verification on a moto g power (2021), Android 11, corrected the
design: a session-0 (global output mix) Visualizer fails with ERROR_NO_INIT
for a normal app even with RECORD_AUDIO + MODIFY_AUDIO_SETTINGS — global
output capture is privileged. So the global-mix `:mix` path can't work in
the core framework.

Revised output_level/1:
- default source is now :mob — meters Mob.Audio's own player. iOS via
  AVAudioPlayer metering; Android via a Visualizer on the player's OWN
  audio session (audioPlayer.audioSessionId), which works with RECORD_AUDIO.
- :mix returns {:error, :unsupported_on_platform} on both platforms. True
  global/foreign-app capture is deferred to a separate MediaProjection-based
  plugin (test-env dep) — see the decision doc.

The Android NIF now decodes a length-coded return (float[2] = level;
float[1] = error code 1 unsupported / 2 needs_record_audio / 3 not_playing).

Verified on hardware (dist-RPC into doom_demo):
  output_status      → %{route: :speaker, volume: 0.2, muted: false}
  output_level(:mob) → {-34.8, -31.8} playing; {:error, :not_playing} idle
  output_level(:mix) → {:error, :unsupported_on_platform}
  no RECORD_AUDIO    → {:error, :needs_record_audio}
  app boots cleanly (NIF table OK)

output_status/0 is unchanged and fully verified. Companion template update:
mob_new (own-session Visualizer, no session-0 tap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A build-time `deps` symlink (→ an absolute local path) was accidentally
staged by `git add -A` in the integration worktree and rode the GenericJam#54 squash
into master. It's a self-referential absolute-path symlink that breaks any
other checkout. Untrack it; deps/ is regenerated by `mix deps.get` and is
gitignored (/deps/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icJam#69)

- Mob.Audio.output_status/0 + output_level/1 — verify sound is actually playing (GenericJam#54)
- Mob.Audio.start_input_metering/1 + input_level/0 + stop_input_metering/1 — agent ears (GenericJam#67, MOB-35)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…am#70)

* Fix iOS Motion accel: match Android units + sign convention

`Mob.Motion` documents `accel` as m/s² with gravity included, but the iOS
NIF emitted `userAcceleration + gravity` straight from CoreMotion — which is
in G (~1.0, not ~9.81) AND uses iOS's own sign convention: the gravity vector
points down, so at rest the up-axis reads -g. Android's SensorManager reports
specific force (proper acceleration), a_coord - g_field, so at rest the up-axis
reads +g. The two were off by both a scale factor and a sign, so any tilt- or
shake-driven UI (e.g. a mascot whose pupils follow gravity) barely moved on iOS
and moved backwards when it did.

Emit `(userAcceleration - gravity) * 9.80665` instead. That is exactly Android's
a_coord - g_field: +g on the up-axis at rest, m/s², correct for both the static
tilt term and the dynamic linear term (not merely a rest-time negation). gyro
(rad/s) and mag (µT) already matched and are unchanged.

Also document the `accel` convention explicitly in the moduledoc so it's a
stated cross-platform contract, not folklore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add source guard for the iOS accel convention

The accel normalization is ObjC inside the CoreMotion callback — no host
seam to unit-test, and the iOS Simulator delivers no motion data, so it's
not integration-testable off a physical device. Guard it the way
Mob.NifDeclarationTest guards the NIF tables: parse ios/mob_nif.m and assert
each accel axis SUBTRACTS gravity (Android specific-force sign, not iOS's
inverted `+ gravity`) and scales G→m/s². Fails on a revert to the old form.

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 GenericJam#70: Mob.Motion iOS `accel` now matches Android's m/s² units and
specific-force sign convention — (userAcceleration - gravity) * 9.80665
instead of the raw CoreMotion userAcceleration + gravity (G, inverted).
Fixes tilt/shake UIs that barely moved and moved backwards on iOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The iOS test harness is compiled out of release builds (`#if !MOB_RELEASE`)
because its synthetic-input NIFs (tap/type/swipe…) use PRIVATE UIKit/IOKit
selectors the App Store auto-rejects. `screenshot/3` was collateral: it uses
only public APIs (UIGraphicsImageRenderer + drawViewHierarchy) but lived in the
same block, so release builds couldn't screenshot at all — e.g. an agent driving
a shipped app over dist can't see the screen to error-correct, and it returns
`:not_loaded`.

Carve `nif_screenshot` + its registration into a separate guard,
`#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)`. Behaviour is unchanged by
default (still stripped in release); a host opts in with -DMOB_ENABLE_SCREENSHOT
(plumbed from mob_dev — companion PR). The private synthetic-input NIFs stay
strictly `#if !MOB_RELEASE` and can never ship in release. So a release build can
SEE the screen (opt-in) but never DRIVE it.

Opt-in by design: screenshot captures the app's own key window with no OS prompt
or indicator, so shipping a remotely-triggerable capture must be a conscious
build choice, never a silent default.

Source-guard test pins the boundary (screenshot opt-in; private-input NIFs never).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships GenericJam#71: carve the public-API screenshot NIF into
`#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)` so a host can opt it into
release builds (via mob_dev's ios_release_screenshot config) — letting an agent
see a shipped app's screen to error-correct. Default unchanged (stripped);
private synthetic-input NIFs stay stripped regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
guides/screen_lifecycle.md, navigation.md, Mob.Screen moduledoc, and PLAN.md
claimed each screen in the nav stack is a separate supervised process and
that popping calls terminate/2. In reality one Mob.Screen GenServer owns the
whole stack: push mounts in the same process, pop restores a snapshot from
nav_history. terminate/2 fires only when the whole GenServer stops. Rewrite
the affected sections to match the shipped model.
@GenericJam

Copy link
Copy Markdown
Owner

Triage note (draft, so no full review yet): the premise checks out — current Mob.Screen really is single-GenServer navigation ({module, socket, nav_history, render_mode}; push mounts into the same process, pop/reset restore snapshots without lifecycle callbacks), so this corrects real doc/code drift. However, a restructure toward per-screen processes is actively being explored, which would re-stale exactly the sections this PR fixes. Suggest holding further polish until that direction settles — we'll ping here when it does so you aren't diffing against a moving target. Thanks for the careful work either way.

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.

5 participants