Fix vertical scroll width after iPad resize - #75
Conversation
…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.
…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>
|
Thanks for this — the mechanism is plausible and the merged tree compiles and passes the suite cleanly, but review turned up two blocking concerns before this can land:
Smaller notes for the next revision: the restructure drops two load-bearing comments (the Mob.Test scroll_info accessibility-id note and the iOS-17 scroll-observer caveat) and duplicates the modifier chain into both branches; if vertical scrolls retain stale width, horizontal scrolls presumably retain stale height (left untouched); and a CHANGELOG entry under [Unreleased] would be appreciated. We don't have iPad hardware in the automated pool, so your physical-device repro evidence genuinely matters here. |
Summary
Bind vertical scroll content to the live SwiftUI viewport width so rotating or resizing an iPad scene does not retain a portrait-sized content proposal. Horizontal scrolling remains unchanged.
Validation
No hard-coded device dimensions.