From 3d99e27341dc1651b9866c4e9fc1e2aade058c08 Mon Sep 17 00:00:00 2001 From: leonardb Date: Tue, 28 Apr 2026 08:30:58 -0400 Subject: [PATCH 001/254] Fix device_capabilities.md to remove spurious triple back-ticks Remove the extra triple back-ticks which were breaking rendering of documentation --- guides/device_capabilities.md | 1 - 1 file changed, 1 deletion(-) diff --git a/guides/device_capabilities.md b/guides/device_capabilities.md index 7327871b..5638f088 100644 --- a/guides/device_capabilities.md +++ b/guides/device_capabilities.md @@ -136,7 +136,6 @@ end ``` > **Platform note:** `types` uses iOS UTI strings on iOS (`"public.pdf"`) and MIME type strings on Android (`"application/pdf"`). To support both platforms with the same call, pass both forms — the platform ignores strings it doesn't recognise. See [Platform-specific props](components.md#platform-specific-props) for a cleaner pattern. -``` ## Camera preview From ea1f12a5785388a374dfc15b6cdc8c54efdf1143 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 08:59:01 -0600 Subject: [PATCH 002/254] docs: log iter 13b + 13c, declare Phase 2 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iter 13b — build.sh.eex eliminated; iOS sim build glue (mix compile, beam copies, exqlite NIF cross-compile, Pythonx framework, crypto shim, ssl beams, Phoenix assets, OTP runtime sync, enif_keepalive, zig binary, .app bundle, simctl install) moved into MobDev.NativeBuild. LV detection gated on `assets/` at project root (not on transitive phoenix_live_view dep — vanilla mob pulls it in too). iter 13c — build_device.sh eliminated; iOS device build glue moves into Mix via the same model. Reuses iter 13b helpers verbatim where the shape is identical; adds device-specific helpers (cross_compile_exqlite_nif_device static .a, maybe_setup_pythonx_device single-arch, maybe_install_ssl_shim full stub, copy_otp_libs_for_phoenix, install_app_in_otp_lib, copy_mob_logos_to_otp_root, patch_epmd_source NO_DAEMON guard, generate_erl_errno_compat_stub, zig_build_binary_ios_device). Stale `has_ios_project?` checks updated to look for `ios/build.zig` across mob.doctor + mob.install + battery_bench docstring. Phase 2 is COMPLETE. Every native build target — iOS sim vanilla, iOS sim LiveView, Android arm64, Android arm32, Android sqlite3_nif, iOS device — has its native compile + link in build.zig and its bundle/install in Mix (or Gradle for Android). The iOS path no longer uses any shell glue. --- build_system_migration.md | 50 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index 163d8c49..cc1bb8d6 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -636,9 +636,53 @@ Apple framework module maps under -fmodules — Phase 1 finding). install. The Mix-driven path resolved an install-acceptance issue the shell flow had hit at the device-trust layer. + - iter 13a: slim_step pipeline restored in Elixir (was a TODO in + iter 12d's bundle/codesign migration). Mirrors the original shell + version: apple binaries strip, prefix libs strip, foreign apps + strip, dedup versions, src+headers strip, beam chunk strip via + `:beam_lib.strip_release/1`. Gated on `MOB_SLIM=1` to keep dev + iteration fast (the strip pass adds ~5-10s). + + - iter 13b: iOS sim build glue → Mix. The generated + `ios/build.sh.eex` template (288 lines) is gone. All iOS-sim + build glue (mix compile, BEAM copies, exqlite NIF cross-compile, + Pythonx framework + cross-compile, crypto shim for LV, + ssl beams from host OTP for LV, Phoenix asset build for LV, + Ecto migration copy, Elixir/EEx stdlib copy, OTP runtime sync, + enif_keepalive generation, zig binary build, .app bundle, simctl + install) now flows through MobDev.NativeBuild. LV detection gates + on `assets/` at project root (not on transitive phoenix_live_view + dep — vanilla mob pulls it in too). Smoke-tested LV (Phoenix 1.7) + and vanilla mob projects on both iOS sim and physical iPhone. + Companion mob_new commit removes `liveview_build_sh_content/2` + and the build.sh template. + + - iter 13c: eliminate `build_device.sh`. Same model as iter 13b but + for iOS device. `generate_build_device_sh/2` (~520 lines) is gone; + `build_ios_physical/2` is now a `with` chain over Mix helpers. + Reuses iter 13b helpers verbatim (compile, beam copy, exqlite OTP + lib, crypto shim, Elixir/EEx stdlib, migrations, Phoenix assets, + enif_keepalive). Adds device-specific helpers: + cross_compile_exqlite_nif_device (static .a, iphoneos arm64); + maybe_setup_pythonx_device (Python.framework rsync + + libpythonx.so for iphoneos arm64); maybe_install_ssl_shim + (LV-only full SSL stub); copy_otp_libs_for_phoenix (runtime_tools, + asn1, public_key from host OTP); install_app_in_otp_lib (so + Plug.Static's :code.lib_dir resolves); copy_mob_logos_to_otp_root; + patch_epmd_source (idempotent NO_DAEMON guard); + generate_erl_errno_compat_stub; zig_build_binary_ios_device. + Stale references swept: `has_ios_project?/0` in mob.doctor + + mob.install now checks `ios/build.zig`; doctor python3/rsync + rationale + battery_bench docstring updated; enable.ex's + detect_stale_pythonx_templates drops the obsolete build.sh entries. + Smoke-tested LV (Phoenix 1.7) and vanilla mob on Kevin's iPhone: + both deploy clean. + **Phase 2 is COMPLETE.** Every target — iOS sim vanilla, iOS sim LiveView, Android arm64, Android arm32, Android sqlite3_nif, iOS device — has its native compile + link in build.zig and its - bundle/install in Mix (or Gradle for Android). Shell scripts are - glue (asset copies, mix compile orchestration, exqlite NIF special - cases), not native build orchestration. + bundle/install in Mix (or Gradle for Android). Both + `ios/build.sh.eex` and runtime-generated `build_device.sh` are + gone. The iOS path no longer uses shell scripts at all; build + orchestration lives in Elixir (`mob_dev/lib/mob_dev/native_build.ex`) + and Zig (`build.zig` / `build_device.zig`). From f768d553cdd2f3087fd3dd016fb91026ea288c0b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 09:02:44 -0600 Subject: [PATCH 003/254] =?UTF-8?q?docs:=20log=20iter=2013e=20=E2=80=94=20?= =?UTF-8?q?incremental=20build=20timing=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-change iOS sim rebuild: 2m12s end-to-end (vanilla project). Dominant costs (OTP runtime rsync, exqlite recompile, bundle assembly) were unconditional in the old shell pipeline too — iter 13b/c preserves original semantics. No regression introduced by the migration; caching wins are tracked as separate optimization work outside Phase 2 scope. --- build_system_migration.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index cc1bb8d6..b8843469 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -678,6 +678,18 @@ Apple framework module maps under -fmodules — Phase 1 finding). Smoke-tested LV (Phoenix 1.7) and vanilla mob on Kevin's iPhone: both deploy clean. + - iter 13e: timing validation. Measured no-change rebuild on iOS + sim (vanilla phase2q_smoke project): 2m12s end-to-end. Time is + dominated by: + - OTP runtime rsync to `~/.mob/runtime/ios-sim` (~195 MB) + - exqlite NIF cross-compile (always re-runs) + - .app bundle rebuild (full rsync of OTP into the bundle) + All three are operations the old shell pipeline did unconditionally + too — iter 13b/c preserves the original semantics, no regression. + Future caching wins (skip rsync when sources unchanged, mtime- + gated exqlite recompile, content-hashed bundle reuse) are out of + scope for Phase 2 cleanup; tracked as separate optimization work. + **Phase 2 is COMPLETE.** Every target — iOS sim vanilla, iOS sim LiveView, Android arm64, Android arm32, Android sqlite3_nif, iOS device — has its native compile + link in build.zig and its From 1256e9c476e6127a83040084c27f78f4d369953c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:02:14 -0600 Subject: [PATCH 004/254] =?UTF-8?q?docs:=20log=20iter=2013d=20findings=20?= =?UTF-8?q?=E2=80=94=20release-script=20zig=20cc=20swap=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched and prototyped the swap from xcrun cc → zig cc -target … in scripts/release/xcomp/erl-xcomp-*.conf. Two real blockers identified: 1. zig cc 0.17.0-dev's iOS support: requires -nostdlibinc + -isysroot + -isystem $SDK/usr/include (not just -isysroot, despite docs). When -lc++ is passed, zig tries to provide its own libcxx for the iOS-sim target and fails on missing iOS-sim libc fragments. Workaround: drop -lc++ (safe for OTP since --disable-jit removes the only C++ surface). 2. OTP's emulator Makefile.in dep machinery: uses custom -MM -MG pass with $(SED_DEPEND) post-processing. zig cc's -MM output format (different absolute-path conventions) breaks this and produces "No rule to make target 'stdbool.h'" errors during the actual build (not configure — passes cleanly). Android: zig rejects aarch64-linux-android24 outright as UnknownApplicationBinaryInterface; workarounds fail OTP autoconf. Deferred indefinitely. The dev path already uses zig cc for everything that matters. Release scripts run rarely (once per OTP bump), produce working tarballs with the existing toolchain, and patching OTP's emulator dep generator is OTP-internal engineering beyond Phase 2 cleanup scope. Revisit when zig has first-class Apple SDK / Android NDK support. --- build_system_migration.md | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index b8843469..cd61aea1 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -678,6 +678,64 @@ Apple framework module maps under -fmodules — Phase 1 finding). Smoke-tested LV (Phoenix 1.7) and vanilla mob on Kevin's iPhone: both deploy clean. + - iter 13d: release scripts → zig cc — **researched, blocked, deferred**. + Goal was to swap `xcrun -sdk … cc` (and Android NDK clang) for + `zig cc -target …` in `scripts/release/xcomp/erl-xcomp-*.conf` so + the OTP tarball is built with the same toolchain the dev path uses. + + **What works:** + - zig cc 0.17.0-dev compiles + links iOS sim/device executables + with these specific flags (verified via standalone hello-world): + `zig cc -target aarch64-ios-simulator -nostdlibinc \` + ` -isysroot $SDK -isystem $SDK/usr/include -L$SDK/usr/lib` + The `-nostdlibinc` is required because zig's bundled stdlib + headers don't match iOS; `-isystem $SDK/usr/include` provides + Apple's iOS SDK headers explicitly (NOT picked up via + `-isysroot` alone in zig 0.17.0-dev). + - With these flags, OTP's autoconf passes `checking whether the + C compiler works... yes` for every subdir. + - Several OTP libraries (erl_interface, ei) build cleanly. + + **What blocks:** + - zig cc tries to provide its own libc++ when `-lc++` is in + LDFLAGS, which fails for iOS sim with cascade of "unknown type + mbstate_t / wint_t / size_t" errors against zig's bundled + libcxx headers (zig doesn't ship iOS-sim libc fragments). + Workaround: drop `-lc++` from LDFLAGS — works for OTP since + `--disable-jit --without-wx` removes the only C++ surfaces. + - After getting past configure + early lib builds, OTP's + emulator build fails: + `gmake[4]: *** No rule to make target 'stdbool.h', needed by` + `'obj/aarch64-apple-iossimulator/opt/emu/erl_main.o'. Stop.` + Root cause: OTP's emulator Makefile.in uses `-MM -MG` for its + custom dep-generation pass (`$(SED_DEPEND) $@.tmp > $@`). With + zig cc, `-MM` outputs only user headers + zig's stdbool.h + absolute path (`/Users/kevin/zig/.../include/stdbool.h`), + which OTP's SED_DEPEND step rewrites to a bare basename that + then has no make-rule to satisfy it. Apple's clang outputs + similar absolute paths but OTP's SED_DEPEND was tuned to its + output format. Patching OTP's emulator dep machinery to + tolerate zig's output format is OTP-internal engineering, not + Phase 1 cleanup work. + - Android: zig 0.17.0-dev rejects `aarch64-linux-android24` as + `UnknownApplicationBinaryInterface`. Workarounds (musl target + + NDK sysroot) don't survive OTP's autoconf feature tests. + + **Decision:** iter 13d is **deferred indefinitely**. The dev path + already uses zig cc for everything that matters (driver_tab, + enif_keepalive, the link via xcrun swiftc — see iter 1-12). The + release path runs once per OTP version bump (rare) and + successfully produces working tarballs with `xcrun cc` + NDK + clang. Pushing the swap through would require: + 1. patching OTP's emulator Makefile.in dep generation, OR + 2. building a `zig-cc-wrapper` shell that translates zig's dep + output into OTP-friendly format + Neither is justified by the marginal benefit (one-fewer + toolchain on the release machine, which is Kevin's Mac that + already has Xcode + NDK). Revisit when zig has first-class Apple + SDK + Android NDK support, or if a future OTP cleanup makes the + emulator dep machinery less custom. + - iter 13e: timing validation. Measured no-change rebuild on iOS sim (vanilla phase2q_smoke project): 2m12s end-to-end. Time is dominated by: From fe1c2f03e9ad3dc6e812a5f40b60775593e5703d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:21:27 -0600 Subject: [PATCH 005/254] lint infra: format-pass clean across all native sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-commit checklist in CLAUDE.md hadn't been run end-to-end in a while; this is the cleanup to make every step actually pass. * `clang-format -i` on every ios/*.m, ios/*.c, android/jni/*.{c,h}. Pure formatting (whitespace, brace placement). No behavior change. * `swiftlint --fix` on ios/ — mechanical cleanups (drop redundant `= nil` on Optionals, tidy line wrapping in MobRootView / MobViewModel). * Add `.swiftlint.yml` tuned to the iOS renderer's natural shape: - identifier_name: 1-char min (allow dx/dy/dt, cb, n, i, … idiomatic short names that are clearer than long aliases) - cyclomatic_complexity / function_body_length / file_length: bumped — MobRootView.swift's renderer is one big switch by design and splitting it scatters the prop-mapping logic - force_cast: warning (one site: `layer as! AVCaptureVideoPreviewLayer` in CameraPreviewUIView, guaranteed by `override class var layerClass`) - multiple_closures_with_trailing_closure: disabled (Apple's own SwiftUI examples use trailing-closure syntax for Button(action:) { label } and onScrollGeometryChange(for:of:)) - line_length: 130 (a few SwiftUI chains read better unwrapped) Net result: 0 errors, 1 documentary warning (the layer cast above). * `mix erlfmt --write src/mob_nif.erl` — trailing whitespace fix. * Add `lib/mix/tasks/erlfmt.ex` — a minimal Mix.Task wrapper around erlfmt's library API (`:erlfmt.format_file/2`). Upstream erlfmt ships an escript build but no mix task; CLAUDE.md's checklist instruction `mix erlfmt --check src/` was aspirational until now. Uses `:unicode.characters_to_binary/1` (not iolist_to_binary) so em-dashes and other non-Latin-1 codepoints in source comments don't crash the writer. Verified end-to-end with `mise exec` (which picks up the `.tool-versions` pin for OTP 29.0-rc3 / Elixir 1.20.0-rc.4-otp-29): mix test → 729 passed (27 doctests, 702 tests) mix format → clean mix credo --strict → 876 mods/funs, 0 issues mix erlfmt --check → all formatted clang-format → clean swiftlint → 1 documentary warning, 0 errors The OTP-version note matters: credo 1.7.18 + OTP 28.0 crash with `:re.import/1 is undefined` (precompiled regex incompat — see mob_dev's CLAUDE.md for the same documented issue). OTP 28.1+ or 29.0-rc2+ recompile regexes at runtime and run clean. --- .swiftlint.yml | 48 + android/jni/driver_tab_android.c | 44 +- android/jni/mob_beam.c | 189 +- android/jni/mob_beam.h | 67 +- android/jni/mob_nif.c | 1939 +++++++----- ios/MobNode.m | 58 +- ios/MobRootView.swift | 37 +- ios/MobViewModel.swift | 4 +- ios/driver_tab_ios.c | 53 +- ios/mob_beam.m | 166 +- ios/mob_nif.m | 5102 ++++++++++++++++-------------- lib/mix/tasks/erlfmt.ex | 99 + src/mob_nif.erl | 497 +-- 13 files changed, 4635 insertions(+), 3668 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 lib/mix/tasks/erlfmt.ex diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..88f2f41a --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,48 @@ +included: + - ios + +# Mob's iOS renderer is a single SwiftUI file by design — it bridges the BEAM +# diff stream to a switch-on-node-type renderer. The "errors" SwiftLint flags +# below are stylistic, not bugs; tuning them lets the actual signal through. + +identifier_name: + # Allow short idiomatic names (a/b for tuple components, n/i for indices, + # dx/dy/dt for deltas, op for operation, cb for callback, pt/r/g/h for + # geometry helpers, ms for milliseconds, t for time/temp). Min length lower + # than 3 catches genuinely opaque names like `_a` while letting these stay. + min_length: + warning: 1 + error: 1 + # Existing 40-char max stays as warning — keeps catching genuinely runaway + # names without escalating. + +# MobRootView.swift's renderer uses a big switch over node types. Splitting +# it would scatter the prop-mapping logic; the function reads top-to-bottom +# and each case is small. Same for the file as a whole. +cyclomatic_complexity: + warning: 40 + error: 60 + +file_length: + warning: 2000 + error: 3000 + +function_body_length: + warning: 100 + error: 200 + +# Force-cast in the bridging code is intentional: the BEAM-side type is +# guaranteed by the encoder. Treat as warning, not error. +force_cast: warning + +# SwiftUI initializers like Button(action:, label:) and modifiers like +# onScrollGeometryChange(for:of:) take two closures by API design. Apple's +# own examples use trailing-closure syntax. Suppress. +disabled_rules: + - multiple_closures_with_trailing_closure + +# 130-char limit (default 120). The renderer has a few SwiftUI chains that +# read better on one line; wrapping just to satisfy 120 hurts readability. +line_length: + warning: 130 + error: 200 diff --git a/android/jni/driver_tab_android.c b/android/jni/driver_tab_android.c index 4d08cd00..247ac575 100644 --- a/android/jni/driver_tab_android.c +++ b/android/jni/driver_tab_android.c @@ -13,26 +13,29 @@ #include -typedef struct { void* de; int flags; } ErtsStaticDriver; +typedef struct { + void *de; + int flags; +} ErtsStaticDriver; #define THE_NON_VALUE ((unsigned long)0) typedef struct { - void* (*nif_init)(void); - int is_builtin; + void *(*nif_init)(void); + int is_builtin; unsigned long nif_mod; - void* entry; + void *entry; } ErtsStaticNif; -typedef struct { void* de; int flags; } ErlDrvEntryStub; +typedef struct { + void *de; + int flags; +} ErlDrvEntryStub; extern ErlDrvEntryStub inet_driver_entry; extern ErlDrvEntryStub ram_file_driver_entry; -ErtsStaticDriver driver_tab[] = { - {&inet_driver_entry, 0}, - {&ram_file_driver_entry, 0}, - {NULL, 0} -}; +ErtsStaticDriver driver_tab[] = {{&inet_driver_entry, 0}, {&ram_file_driver_entry, 0}, {NULL, 0}}; -void erts_init_static_drivers(void) {} +void erts_init_static_drivers(void) { +} void *prim_tty_nif_init(void); void *erl_tracer_nif_init(void); @@ -57,16 +60,9 @@ void *crypto_nif_init(void); void *mob_nif_nif_init(void); ErtsStaticNif erts_static_nif_tab[] = { - {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, - {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, - {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, - {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, - {NULL, 0, THE_NON_VALUE, NULL} -}; + {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, + {zlib_nif_init, 0, THE_NON_VALUE, NULL}, {zstd_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, + {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, {crypto_nif_init, 1, THE_NON_VALUE, NULL}, + {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, {NULL, 0, THE_NON_VALUE, NULL}}; diff --git a/android/jni/mob_beam.c b/android/jni/mob_beam.c index 7f089284..66f4ca66 100644 --- a/android/jni/mob_beam.c +++ b/android/jni/mob_beam.c @@ -1,22 +1,22 @@ // mob_beam.c — Mob BEAM launcher and JNI bridge initialisation. // Extracted from the per-app beam_jni.c stub so app code stays minimal. -#include +#include "mob_beam.h" #include +#include +#include +#include +#include +#include +#include #include #include +#include #include -#include #include -#include -#include -#include -#include -#include -#include "mob_beam.h" #define LOG_TAG "MobBeam" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) // ── BEAM stdout/stderr → logcat ────────────────────────────────────────── @@ -28,7 +28,7 @@ // // See beam_crash.md (Incident #1) for the case that motivated this. -static void* mob_beam_log_reader(void* arg) { +static void *mob_beam_log_reader(void *arg) { int fd = (int)(intptr_t)arg; char buf[4096]; char line[4096]; @@ -64,8 +64,7 @@ static void mob_capture_beam_stdio(void) { close(pipe_fds[1]); pthread_t tid; - if (pthread_create(&tid, NULL, mob_beam_log_reader, - (void*)(intptr_t)pipe_fds[0]) != 0) { + if (pthread_create(&tid, NULL, mob_beam_log_reader, (void *)(intptr_t)pipe_fds[0]) != 0) { LOGE("mob_capture_beam_stdio: pthread_create failed: %s", strerror(errno)); close(pipe_fds[0]); return; @@ -79,23 +78,23 @@ static void mob_capture_beam_stdio(void) { LOGI("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)"); } -#define ERTS_VSN "erts-17.0" +#define ERTS_VSN "erts-17.0" // Declared in mob_nif.c — caches MobBridge methods on the main thread. -extern void _mob_ui_cache_class_impl(JNIEnv* env, const char* bridge_class); +extern void _mob_ui_cache_class_impl(JNIEnv *env, const char *bridge_class); // Native lib dir and app files dir — populated in mob_init_bridge, used in mob_start_beam. static char s_native_lib_dir[512] = {0}; -static char s_files_dir[512] = {0}; +static char s_files_dir[512] = {0}; -void mob_ui_cache_class(JNIEnv* env, const char* bridge_class) { +void mob_ui_cache_class(JNIEnv *env, const char *bridge_class) { _mob_ui_cache_class_impl(env, bridge_class); } // Declared in mob_nif.c — the cached Bridge.cls global ref. -extern void _mob_bridge_init_activity(JNIEnv* env, jobject activity); +extern void _mob_bridge_init_activity(JNIEnv *env, jobject activity); -void mob_init_bridge(JNIEnv* env, jobject activity) { +void mob_init_bridge(JNIEnv *env, jobject activity) { // Capture BEAM stdio first so any startup errors (NIF load failures, // application:start/2 crashes) land in logcat instead of /dev/null. mob_capture_beam_stdio(); @@ -108,13 +107,12 @@ void mob_init_bridge(JNIEnv* env, jobject activity) { // allows execve() from untrusted_app, unlike files in app_data_file. jclass ctx_cls = (*env)->FindClass(env, "android/content/Context"); jmethodID get_app_info = (*env)->GetMethodID(env, ctx_cls, "getApplicationInfo", - "()Landroid/content/pm/ApplicationInfo;"); + "()Landroid/content/pm/ApplicationInfo;"); jobject app_info = (*env)->CallObjectMethod(env, activity, get_app_info); jclass app_info_cls = (*env)->FindClass(env, "android/content/pm/ApplicationInfo"); - jfieldID fid = (*env)->GetFieldID(env, app_info_cls, "nativeLibraryDir", - "Ljava/lang/String;"); + jfieldID fid = (*env)->GetFieldID(env, app_info_cls, "nativeLibraryDir", "Ljava/lang/String;"); jstring jdir = (*env)->GetObjectField(env, app_info, fid); - const char* dir = (*env)->GetStringUTFChars(env, jdir, NULL); + const char *dir = (*env)->GetStringUTFChars(env, jdir, NULL); snprintf(s_native_lib_dir, sizeof(s_native_lib_dir), "%s", dir); (*env)->ReleaseStringUTFChars(env, jdir, dir); LOGI("mob_init_bridge: native lib dir = %s", s_native_lib_dir); @@ -125,13 +123,13 @@ void mob_init_bridge(JNIEnv* env, jobject activity) { jclass file_cls = (*env)->FindClass(env, "java/io/File"); jmethodID get_path = (*env)->GetMethodID(env, file_cls, "getPath", "()Ljava/lang/String;"); jstring jfiles_path = (*env)->CallObjectMethod(env, files_dir_obj, get_path); - const char* files_path = (*env)->GetStringUTFChars(env, jfiles_path, NULL); + const char *files_path = (*env)->GetStringUTFChars(env, jfiles_path, NULL); snprintf(s_files_dir, sizeof(s_files_dir), "%s", files_path); (*env)->ReleaseStringUTFChars(env, jfiles_path, files_path); LOGI("mob_init_bridge: files dir = %s", s_files_dir); } -void mob_start_beam(const char* app_module) { +void mob_start_beam(const char *app_module) { #ifdef NO_BEAM // Config A: baseline measurement — stock Android activity, BEAM never launched. LOGI("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)"); @@ -145,8 +143,7 @@ void mob_start_beam(const char* app_module) { // `cannot locate symbol enif_get_tuple`. { char self_path[600]; - snprintf(self_path, sizeof(self_path), "%s/lib%s.so", - s_native_lib_dir, app_module); + snprintf(self_path, sizeof(self_path), "%s/lib%s.so", s_native_lib_dir, app_module); if (!dlopen(self_path, RTLD_NOW | RTLD_GLOBAL)) { LOGE("mob_start_beam: dlopen self with RTLD_GLOBAL failed: %s", dlerror()); } else { @@ -176,11 +173,11 @@ void mob_start_beam(const char* app_module) { char crash_dump[560]; snprintf(crash_dump, sizeof(crash_dump), "%s/erl_crash.dump", s_files_dir); - setenv("BINDIR", bindir, 1); - setenv("ROOTDIR", otp_root, 1); - setenv("PROGNAME", "erl", 1); - setenv("EMU", "beam", 1); - setenv("HOME", s_files_dir, 1); + setenv("BINDIR", bindir, 1); + setenv("ROOTDIR", otp_root, 1); + setenv("PROGNAME", "erl", 1); + setenv("EMU", "beam", 1); + setenv("HOME", s_files_dir, 1); setenv("MOB_DATA_DIR", s_files_dir, 1); // MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed. @@ -199,8 +196,8 @@ void mob_start_beam(const char* app_module) { // is computed here from getFilesDir() at runtime (the path includes the // Android user ID which is not predictable at compile time). setenv("MOB_BEAMS_DIR", beams_dir, 1); - setenv("ERL_CRASH_DUMP", crash_dump, 1); - setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); + setenv("ERL_CRASH_DUMP", crash_dump, 1); + setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); char eval_expr[280]; snprintf(eval_expr, sizeof(eval_expr), "%s:start().", app_module); @@ -211,26 +208,24 @@ void mob_start_beam(const char* app_module) { // These are overridden at runtime if beams_dir/mob_beam_flags exists. #ifdef BEAM_USE_CUSTOM_FLAGS #include "mob_beam_flags.h" - static const char* s_default_flags[] = { BEAM_EXTRA_FLAGS NULL }; + static const char *s_default_flags[] = {BEAM_EXTRA_FLAGS NULL}; #elif defined(BEAM_UNTUNED) - static const char* s_default_flags[] = { NULL }; + static const char *s_default_flags[] = {NULL}; #elif defined(BEAM_SBWT_ONLY) - static const char* s_default_flags[] = { - "-sbwt", "none", "-sbwtdcpu", "none", "-sbwtdio", "none", NULL - }; + static const char *s_default_flags[] = {"-sbwt", "none", "-sbwtdcpu", "none", + "-sbwtdio", "none", NULL}; #else // Default and BEAM_FULL_NERVES both use full Nerves-style tuning. - static const char* s_default_flags[] = { - "-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", "-A", "1", - "-sbwt", "none", "-sbwtdcpu", "none", "-sbwtdio", "none", NULL - }; + static const char *s_default_flags[] = {"-S", "1:1", "-SDcpu", "1:1", "-SDio", + "1", "-A", "1", "-sbwt", "none", + "-sbwtdcpu", "none", "-sbwtdio", "none", NULL}; #endif // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. // Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`. - static char s_flags_buf[512] = {0}; - static const char* s_runtime_flags[64] = {NULL}; - static int s_runtime_flag_count = 0; + static char s_flags_buf[512] = {0}; + static const char *s_runtime_flags[64] = {NULL}; + static int s_runtime_flag_count = 0; { char flags_path[640]; snprintf(flags_path, sizeof(flags_path), "%s/mob_beam_flags", beams_dir); @@ -242,41 +237,54 @@ void mob_start_beam(const char* app_module) { s_runtime_flag_count = 0; char *p = s_flags_buf; while (*p && s_runtime_flag_count < 63) { - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; - if (!*p) break; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + if (!*p) + break; s_runtime_flags[s_runtime_flag_count++] = p; - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; - if (*p) *p++ = '\0'; + while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') + p++; + if (*p) + *p++ = '\0'; } s_runtime_flags[s_runtime_flag_count] = NULL; - LOGI("mob_start_beam: loaded %d runtime flags from %s", s_runtime_flag_count, flags_path); + LOGI("mob_start_beam: loaded %d runtime flags from %s", s_runtime_flag_count, + flags_path); } } - const char** selected_flags = (s_runtime_flag_count > 0) - ? s_runtime_flags - : s_default_flags; + const char **selected_flags = (s_runtime_flag_count > 0) ? s_runtime_flags : s_default_flags; char boot_path[580]; snprintf(boot_path, sizeof(boot_path), "%s/releases/29/start_clean", otp_root); - static const char* args[128]; + static const char *args[128]; int ac = 0; args[ac++] = "beam"; - for (int i = 0; selected_flags[i]; i++) args[ac++] = selected_flags[i]; + for (int i = 0; selected_flags[i]; i++) + args[ac++] = selected_flags[i]; args[ac++] = "--"; - args[ac++] = "-root"; args[ac++] = otp_root; - args[ac++] = "-bindir"; args[ac++] = bindir; - args[ac++] = "-progname"; args[ac++] = "erl"; + args[ac++] = "-root"; + args[ac++] = otp_root; + args[ac++] = "-bindir"; + args[ac++] = bindir; + args[ac++] = "-progname"; + args[ac++] = "erl"; args[ac++] = "--"; args[ac++] = "-noshell"; args[ac++] = "-noinput"; - args[ac++] = "-boot"; args[ac++] = boot_path; - args[ac++] = "-pa"; args[ac++] = elixir_dir; - args[ac++] = "-pa"; args[ac++] = logger_dir; - args[ac++] = "-pa"; args[ac++] = eex_dir; - args[ac++] = "-pa"; args[ac++] = beams_dir; - args[ac++] = "-eval"; args[ac++] = eval_expr; + args[ac++] = "-boot"; + args[ac++] = boot_path; + args[ac++] = "-pa"; + args[ac++] = elixir_dir; + args[ac++] = "-pa"; + args[ac++] = logger_dir; + args[ac++] = "-pa"; + args[ac++] = eex_dir; + args[ac++] = "-pa"; + args[ac++] = beams_dir; + args[ac++] = "-eval"; + args[ac++] = eval_expr; args[ac] = NULL; // ── Cold-start race condition fix ──────────────────────────────────────── @@ -328,12 +336,12 @@ void mob_start_beam(const char* app_module) { // native thread that was never attached would set needs_detach == 1. if (g_jvm && g_activity) { mob_set_startup_phase("Waiting for window focus…"); - JNIEnv* env2 = NULL; - int needs_detach = ((*g_jvm)->GetEnv(g_jvm, (void**)&env2, JNI_VERSION_1_6) != JNI_OK); + JNIEnv *env2 = NULL; + int needs_detach = ((*g_jvm)->GetEnv(g_jvm, (void **)&env2, JNI_VERSION_1_6) != JNI_OK); if (needs_detach) (*g_jvm)->AttachCurrentThread(g_jvm, &env2, NULL); - jclass act_cls = (*env2)->GetObjectClass(env2, g_activity); + jclass act_cls = (*env2)->GetObjectClass(env2, g_activity); jmethodID has_focus = (*env2)->GetMethodID(env2, act_cls, "hasWindowFocus", "()Z"); int waited = 0; const int max_wait = 3000; /* ms — fall through if focus never arrives */ @@ -367,18 +375,13 @@ void mob_start_beam(const char* app_module) { // by checking whether the nativeLibDir target exists: if it doesn't, skip the // unlink+symlink so we don't clobber the already-extracted real file. if (s_native_lib_dir[0]) { - static const char* const exes[] = { - "erl_child_setup", "inet_gethost", "epmd", NULL - }; - static const char* const libs[] = { - "liberl_child_setup.so", "libinet_gethost.so", "libepmd.so", NULL - }; + static const char *const exes[] = {"erl_child_setup", "inet_gethost", "epmd", NULL}; + static const char *const libs[] = {"liberl_child_setup.so", "libinet_gethost.so", + "libepmd.so", NULL}; char bin_path[512], lib_path[512]; for (int i = 0; exes[i]; i++) { - snprintf(bin_path, sizeof(bin_path), - "%s/" ERTS_VSN "/bin/%s", otp_root, exes[i]); - snprintf(lib_path, sizeof(lib_path), - "%s/%s", s_native_lib_dir, libs[i]); + snprintf(bin_path, sizeof(bin_path), "%s/" ERTS_VSN "/bin/%s", otp_root, exes[i]); + snprintf(lib_path, sizeof(lib_path), "%s/%s", s_native_lib_dir, libs[i]); struct stat lib_st; if (stat(lib_path, &lib_st) == 0) { // nativeLibDir has the file (adb install) — use symlink @@ -395,7 +398,8 @@ void mob_start_beam(const char* app_module) { if (stat(bin_path, &bin_st) == 0) { LOGI("mob_start_beam: symlink %s (extracted from split APK)", exes[i]); } else { - LOGE("mob_start_beam: symlink %s missing from both nativeLibDir and bin/", exes[i]); + LOGE("mob_start_beam: symlink %s missing from both nativeLibDir and bin/", + exes[i]); } } } @@ -424,12 +428,11 @@ void mob_start_beam(const char* app_module) { while ((entry = readdir(d)) != NULL) { if (strncmp(entry->d_name, "exqlite-", 8) == 0) { char exqlite_priv[700]; - snprintf(exqlite_priv, sizeof(exqlite_priv), - "%s/%s/priv", lib_path, entry->d_name); + snprintf(exqlite_priv, sizeof(exqlite_priv), "%s/%s/priv", lib_path, + entry->d_name); mkdir(exqlite_priv, 0755); char nif_link[760]; - snprintf(nif_link, sizeof(nif_link), - "%s/sqlite3_nif.so", exqlite_priv); + snprintf(nif_link, sizeof(nif_link), "%s/sqlite3_nif.so", exqlite_priv); struct stat nif_lib_st; if (stat(nif_target, &nif_lib_st) == 0) { // nativeLibDir has the NIF (adb install) — use symlink @@ -447,7 +450,8 @@ void mob_start_beam(const char* app_module) { LOGI("mob_start_beam: exqlite NIF extracted from split APK"); found = 1; } else { - LOGE("mob_start_beam: exqlite NIF missing from both nativeLibDir and priv/"); + LOGE("mob_start_beam: exqlite NIF missing from both nativeLibDir and " + "priv/"); } } break; @@ -470,7 +474,8 @@ void mob_start_beam(const char* app_module) { if (symlink(nif_target, nif_link) == 0) { LOGI("mob_start_beam: symlink sqlite3_nif.so (fallback) -> %s", nif_target); } else { - LOGE("mob_start_beam: symlink sqlite3_nif (fallback) failed: %s", strerror(errno)); + LOGE("mob_start_beam: symlink sqlite3_nif (fallback) failed: %s", + strerror(errno)); } } else { struct stat nif_fb_file_st; @@ -507,19 +512,15 @@ void mob_start_beam(const char* app_module) { while ((entry = readdir(d2)) != NULL) { if (strncmp(entry->d_name, "pythonx-", 8) == 0) { char pyx_priv[700]; - snprintf(pyx_priv, sizeof(pyx_priv), "%s/%s/priv", - lib_path, entry->d_name); + snprintf(pyx_priv, sizeof(pyx_priv), "%s/%s/priv", lib_path, entry->d_name); mkdir(pyx_priv, 0755); char pyx_link[760]; - snprintf(pyx_link, sizeof(pyx_link), "%s/libpythonx.so", - pyx_priv); + snprintf(pyx_link, sizeof(pyx_link), "%s/libpythonx.so", pyx_priv); unlink(pyx_link); if (symlink(pyx_target, pyx_link) == 0) { - LOGI("mob_start_beam: symlink pythonx NIF -> %s", - pyx_target); + LOGI("mob_start_beam: symlink pythonx NIF -> %s", pyx_target); } else { - LOGE("mob_start_beam: symlink pythonx NIF failed: %s", - strerror(errno)); + LOGE("mob_start_beam: symlink pythonx NIF failed: %s", strerror(errno)); } break; } @@ -529,8 +530,8 @@ void mob_start_beam(const char* app_module) { } } - void erl_start(int, char**); - erl_start(ac, (char**)args); + void erl_start(int, char **); + erl_start(ac, (char **)args); mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details"); LOGE("mob_start_beam: erl_start returned (unexpected)"); } diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 56781fff..86aec088 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -8,7 +8,7 @@ // Call from JNI_OnLoad (main thread). // bridge_class: e.g. "com/myapp/MobBridge" -void mob_ui_cache_class(JNIEnv* env, const char* bridge_class); +void mob_ui_cache_class(JNIEnv *env, const char *bridge_class); // Send a tap event to the BEAM process registered for handle. // Called from the app's Java_..._MobBridge_nativeSendTap JNI stub. @@ -16,8 +16,8 @@ void mob_send_tap(int handle); // Send a {:change, tag, value} event. Called from the app's // Java_..._MobBridge_nativeSendChange* JNI stubs. -void mob_send_change_str(int handle, const char* utf8); -void mob_send_change_bool(int handle, int bool_val); // 0 = false, 1 = true +void mob_send_change_str(int handle, const char *utf8); +void mob_send_change_bool(int handle, int bool_val); // 0 = false, 1 = true void mob_send_change_float(int handle, double value); // Send {:focus, tag}, {:blur, tag}, {:submit, tag} events. @@ -32,7 +32,7 @@ void mob_send_select(int handle); // fields. phase is "began" | "updating" | "committed" | "cancelled". Apps // that observe this can implement commit-only behaviour for CJK input // (ignore on_change while composing, replace text on :committed). -void mob_send_compose(int handle, const char* text, const char* phase); +void mob_send_compose(int handle, const char *text, const char *phase); // ── Gesture senders (Batch 4) ──────────────────────────────────────────── // Called from beam_jni.c JNI stubs when Compose's gesture detector fires. @@ -45,29 +45,21 @@ void mob_send_swipe_up(int handle); void mob_send_swipe_down(int handle); // Direction-aware: emits {:swipe, tag, direction_atom} where direction is // "left" | "right" | "up" | "down". -void mob_send_swipe_with_direction(int handle, const char* direction); +void mob_send_swipe_with_direction(int handle, const char *direction); // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ───── // Throttling and delta-thresholding are applied native-side BEFORE these // fire — by the time they're called, the BEAM crossing is justified. // Defaults (when no explicit config): scroll 33ms/1px, drag 16ms/1px, // pinch 16ms/0.01, rotate 16ms/1°, pointer_move 33ms/4px. -void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, +void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, double delta_threshold, int leading, int trailing); // Phase is "began" | "dragging" | "decelerating" | "ended" -void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase); -void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase); -void mob_send_pinch(int handle, double scale, double velocity, const char* phase); -void mob_send_rotate(int handle, double degrees, double velocity, const char* phase); +void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, double vy, + const char *phase); +void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase); +void mob_send_pinch(int handle, double scale, double velocity, const char *phase); +void mob_send_rotate(int handle, double degrees, double velocity, const char *phase); void mob_send_pointer_move(int handle, double x, double y); // ── Batch 5 Tier 2: semantic single-fire scroll events ── @@ -82,55 +74,54 @@ void mob_send_scrolled_past(int handle); void mob_handle_back(void); // Call from nativeSetActivity. -void mob_init_bridge(JNIEnv* env, jobject activity); +void mob_init_bridge(JNIEnv *env, jobject activity); // Call from nativeStartBeam. // app_module: Erlang module name, e.g. "mob_demo" -void mob_start_beam(const char* app_module); +void mob_start_beam(const char *app_module); // Update the startup status shown on screen while BEAM is initialising. // mob_set_startup_error stalls the screen with an error message (does not crash). // Both are safe to call from any thread; no-op if MobBridge lacks the method. -void mob_set_startup_phase(const char* phase); -void mob_set_startup_error(const char* error); +void mob_set_startup_phase(const char *phase); +void mob_set_startup_error(const char *error); // Global JVM pointer — defined in mob_beam.c, extern'd for mob_nif.c. -extern JavaVM* g_jvm; +extern JavaVM *g_jvm; extern jobject g_activity; // ── Device capability delivery functions ───────────────────────────────── // Called from beam_jni.c JNI stubs when Kotlin delivers async results. // pid is an ErlNifPid passed as jlong through Kotlin. -void mob_deliver_atom2(jlong pid, const char* a1, const char* a2); -void mob_deliver_atom3(jlong pid, const char* a1, const char* a2, const char* a3); +void mob_deliver_atom2(jlong pid, const char *a1, const char *a2); +void mob_deliver_atom3(jlong pid, const char *a1, const char *a2, const char *a3); void mob_deliver_location(jlong pid, double lat, double lon, double acc, double alt); -void mob_deliver_motion(jlong pid, double ax, double ay, double az, - double gx, double gy, double gz, long long ts); -void mob_deliver_file_result(jlong pid, const char* event, const char* sub, - const char* json_items); -void mob_deliver_push_token(jlong pid, const char* token); -void mob_deliver_notification(jlong pid, const char* json); -void mob_set_launch_notification(const char* json); +void mob_deliver_motion(jlong pid, double ax, double ay, double az, double gx, double gy, double gz, + long long ts); +void mob_deliver_file_result(jlong pid, const char *event, const char *sub, const char *json_items); +void mob_deliver_push_token(jlong pid, const char *token); +void mob_deliver_notification(jlong pid, const char *json); +void mob_set_launch_notification(const char *json); // Deliver WebView events from Java/Kotlin to the registered owner pid. // `mob_deliver_webview_message` for postMessage payloads from JS, // `mob_deliver_webview_blocked` for navigation attempts to disallowed URLs. -void mob_deliver_webview_message(jlong pid, const char* json); -void mob_deliver_webview_blocked(jlong pid, const char* url); +void mob_deliver_webview_message(jlong pid, const char *json); +void mob_deliver_webview_blocked(jlong pid, const char *url); // Deliver {:alert, action_atom} to the registered :mob_screen process. // Called from beam_jni.c when a dialog button is tapped. -void mob_deliver_alert_action(const char* action); +void mob_deliver_alert_action(const char *action); // Deliver {:component_event, event, payload_json} to a native view component process. // Called from beam_jni.c when Kotlin fires a component event via the send callback. -void mob_send_component_event(int handle, const char* event, const char* payload_json); +void mob_send_component_event(int handle, const char *event, const char *payload_json); // Deliver {:mob_device, :color_scheme_changed, :light | :dark} to the // dispatcher pid registered via Mob.Device. Called from beam_jni.c's // nativeNotifyColorScheme when MainActivity sees a uiMode flip. // `scheme` must be "light" or "dark". -void mob_send_color_scheme_changed(const char* scheme); +void mob_send_color_scheme_changed(const char *scheme); #endif // MOB_BEAM_H diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c index c4d2c652..1e2eb4dd 100644 --- a/android/jni/mob_nif.c +++ b/android/jni/mob_nif.c @@ -7,23 +7,23 @@ // register_tap/1 — register ErlNifPid, get integer handle back // clear_taps/0 — clear tap registry before each render -#include +#include "erl_nif.h" +#include "mob_beam.h" #include +#include #include -#include #include +#include #include -#include "erl_nif.h" -#include "mob_beam.h" #define LOG_TAG "MobNIF" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) // ── Cached JNI method IDs ──────────────────────────────────────────────────── static struct { - jclass cls; + jclass cls; jmethodID set_root; jmethodID move_to_back; jmethodID get_safe_area; @@ -94,26 +94,26 @@ static struct { #define MAX_TAP_HANDLES 256 typedef struct { - ErlNifPid pid; - ErlNifEnv* tag_env; // persistent env owning tag; NULL when not in use - ERL_NIF_TERM tag; // the term sent as the second element of {:tap, tag} + ErlNifPid pid; + ErlNifEnv *tag_env; // persistent env owning tag; NULL when not in use + ERL_NIF_TERM tag; // the term sent as the second element of {:tap, tag} // ── Batch 5 throttle state — populated by mob_set_throttle_config ── - int throttle_ms; - int debounce_ms; - double delta_threshold; - int leading; - int trailing; - long long last_emit_ns; // CLOCK_MONOTONIC ns - double last_x; - double last_y; + int throttle_ms; + int debounce_ms; + double delta_threshold; + int leading; + int trailing; + long long last_emit_ns; // CLOCK_MONOTONIC ns + double last_x; + double last_y; unsigned long long seq; } TapHandle; -static TapHandle tap_handles[MAX_TAP_HANDLES]; -static int tap_handle_next = 0; -static ErlNifMutex* tap_mutex = NULL; -static char g_transition[16] = "none"; // set by set_transition/1, read+reset by set_root/1 +static TapHandle tap_handles[MAX_TAP_HANDLES]; +static int tap_handle_next = 0; +static ErlNifMutex *tap_mutex = NULL; +static char g_transition[16] = "none"; // set by set_transition/1, read+reset by set_root/1 // ── Component handle registry ───────────────────────────────────────────────── // Persistent (not cleared between renders). Each slot maps an integer handle to @@ -123,14 +123,15 @@ static char g_transition[16] = "none"; // set by set_transition/1, read typedef struct { ErlNifPid pid; - int active; + int active; } ComponentHandle; static ComponentHandle component_handles[MAX_COMPONENT_HANDLES]; -static ErlNifMutex* component_mutex = NULL; +static ErlNifMutex *component_mutex = NULL; -void mob_send_component_event(int handle, const char* event, const char* payload_json) { - if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) return; +void mob_send_component_event(int handle, const char *event, const char *payload_json) { + if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) + return; enif_mutex_lock(component_mutex); if (!component_handles[handle].active) { enif_mutex_unlock(component_mutex); @@ -139,11 +140,10 @@ void mob_send_component_event(int handle, const char* event, const char* payload ErlNifPid pid = component_handles[handle].pid; enif_mutex_unlock(component_mutex); - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "component_event"), - enif_make_string(env, event, ERL_NIF_LATIN1), - enif_make_string(env, payload_json, ERL_NIF_LATIN1)); + ErlNifEnv *env = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "component_event"), + enif_make_string(env, event, ERL_NIF_LATIN1), + enif_make_string(env, payload_json, ERL_NIF_LATIN1)); enif_send(NULL, &pid, env, msg); enif_free_env(env); } @@ -156,15 +156,14 @@ void mob_send_tap(int handle) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; - ErlNifEnv* tag_env = tap_handles[handle].tag_env; - ERL_NIF_TERM tag = tap_handles[handle].tag; + ErlNifPid pid = tap_handles[handle].pid; + ErlNifEnv *tag_env = tap_handles[handle].tag_env; + ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, "tap"), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, "tap"), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); (void)tag_env; // owned by tap_handles; freed in clear_taps @@ -180,21 +179,20 @@ static void send_change(int handle, ERL_NIF_TERM value_term) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "change"), - enif_make_copy(msg_env, tag), - enif_make_copy(msg_env, value_term)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "change"), enif_make_copy(msg_env, tag), + enif_make_copy(msg_env, value_term)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -void mob_send_change_str(int handle, const char* utf8) { - ErlNifEnv* tmp = enif_alloc_env(); +void mob_send_change_str(int handle, const char *utf8) { + ErlNifEnv *tmp = enif_alloc_env(); ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -205,14 +203,14 @@ void mob_send_change_str(int handle, const char* utf8) { } void mob_send_change_bool(int handle, int bool_val) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); send_change(handle, term); enif_free_env(tmp); } void mob_send_change_float(int handle, double value) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_double(tmp, value); send_change(handle, term); enif_free_env(tmp); @@ -222,44 +220,51 @@ void mob_send_change_float(int handle, double value) { // Called from beam_jni.c JNI stubs when a text field gains/loses focus or // the return key is pressed. Sends a {:event, tag} 2-tuple to the registered pid. -static void send_event(int handle, const char* atom) { +static void send_event(int handle, const char *atom) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, atom), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, atom), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -void mob_send_focus(int handle) { send_event(handle, "focus"); } -void mob_send_blur(int handle) { send_event(handle, "blur"); } -void mob_send_submit(int handle) { send_event(handle, "submit"); } -void mob_send_select(int handle) { send_event(handle, "select"); } +void mob_send_focus(int handle) { + send_event(handle, "focus"); +} +void mob_send_blur(int handle) { + send_event(handle, "blur"); +} +void mob_send_submit(int handle) { + send_event(handle, "submit"); +} +void mob_send_select(int handle) { + send_event(handle, "select"); +} // IME composition. Sends {compose, tag, %{text, phase}} where phase is // began/updating/committed/cancelled. Called from beam_jni.c when the // Compose TextField's TextFieldValue.composition range changes, or from // an InputConnection observer in legacy view-system text fields. -void mob_send_compose(int handle, const char* text, const char* phase) { +void mob_send_compose(int handle, const char *text, const char *phase) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM keys[2] = { enif_make_atom(env, "text"), enif_make_atom(env, "phase"), @@ -270,10 +275,8 @@ void mob_send_compose(int handle, const char* text, const char* phase) { }; ERL_NIF_TERM payload; enif_make_map_from_arrays(env, keys, vals, 2, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "compose"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "compose"), enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } @@ -283,28 +286,39 @@ void mob_send_compose(int handle, const char* text, const char* phase) { // per-widget opt-in — only registered handles emit. Direction-aware swipes // use mob_send_swipe_with_direction. -void mob_send_long_press(int handle) { send_event(handle, "long_press"); } -void mob_send_double_tap(int handle) { send_event(handle, "double_tap"); } -void mob_send_swipe_left(int handle) { send_event(handle, "swipe_left"); } -void mob_send_swipe_right(int handle) { send_event(handle, "swipe_right"); } -void mob_send_swipe_up(int handle) { send_event(handle, "swipe_up"); } -void mob_send_swipe_down(int handle) { send_event(handle, "swipe_down"); } +void mob_send_long_press(int handle) { + send_event(handle, "long_press"); +} +void mob_send_double_tap(int handle) { + send_event(handle, "double_tap"); +} +void mob_send_swipe_left(int handle) { + send_event(handle, "swipe_left"); +} +void mob_send_swipe_right(int handle) { + send_event(handle, "swipe_right"); +} +void mob_send_swipe_up(int handle) { + send_event(handle, "swipe_up"); +} +void mob_send_swipe_down(int handle) { + send_event(handle, "swipe_down"); +} -void mob_send_swipe_with_direction(int handle, const char* direction) { +void mob_send_swipe_with_direction(int handle, const char *direction) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "swipe"), - enif_make_copy(msg_env, tag), - enif_make_atom(msg_env, direction)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "swipe"), enif_make_copy(msg_env, tag), + enif_make_atom(msg_env, direction)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -320,29 +334,27 @@ static long long mob_now_ns_android(void) { return (long long)ts.tv_sec * 1000000000LL + (long long)ts.tv_nsec; } -void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, +void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, double delta_threshold, int leading, int trailing) { enif_mutex_lock(tap_mutex); if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { - tap_handles[handle].throttle_ms = throttle_ms; - tap_handles[handle].debounce_ms = debounce_ms; + tap_handles[handle].throttle_ms = throttle_ms; + tap_handles[handle].debounce_ms = debounce_ms; tap_handles[handle].delta_threshold = delta_threshold; - tap_handles[handle].leading = leading; - tap_handles[handle].trailing = trailing; + tap_handles[handle].leading = leading; + tap_handles[handle].trailing = trailing; } enif_mutex_unlock(tap_mutex); } -static int mob_throttle_check_a(int handle, double x, double y, - int default_throttle_ms, double default_delta) { +static int mob_throttle_check_a(int handle, double x, double y, int default_throttle_ms, + double default_delta) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return 0; } - TapHandle* h = &tap_handles[handle]; + TapHandle *h = &tap_handles[handle]; int throttle_ms = h->throttle_ms ? h->throttle_ms : default_throttle_ms; double delta_threshold = h->delta_threshold > 0 ? h->delta_threshold : default_delta; @@ -372,214 +384,205 @@ static int mob_throttle_check_a(int handle, double x, double y, } // Build payload map for scroll/drag/etc. Caller owns msg_env. -static ERL_NIF_TERM mob_build_scroll_map(ErlNifEnv* env, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase, - long long ts_ms, +static ERL_NIF_TERM mob_build_scroll_map(ErlNifEnv *env, double x, double y, double dx, double dy, + double vx, double vy, const char *phase, long long ts_ms, unsigned long long seq) { ERL_NIF_TERM keys[9] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), + enif_make_atom(env, "x"), enif_make_atom(env, "y"), + enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), enif_make_atom(env, "velocity_x"), enif_make_atom(env, "velocity_y"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), + enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), + enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[9] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, dx), enif_make_double(env, dy), - enif_make_double(env, vx), enif_make_double(env, vy), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), - enif_make_uint64(env, seq), + enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), + enif_make_double(env, dy), enif_make_double(env, vx), enif_make_double(env, vy), + enif_make_atom(env, phase), enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), }; ERL_NIF_TERM map; enif_make_map_from_arrays(env, keys, vals, 9, &map); return map; } -void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase) { +void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, double vy, + const char *phase) { int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 33, 1.0)) return; + if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 33, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; unsigned long long seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM payload = mob_build_scroll_map(env, x, y, dx, dy, vx, vy, phase, ts_ms, seq); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "scroll"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "scroll"), enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } -void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase) { +void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase) { int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 16, 1.0)) return; + if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; unsigned long long seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM keys[7] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), + enif_make_atom(env, "x"), enif_make_atom(env, "y"), enif_make_atom(env, "dx"), + enif_make_atom(env, "dy"), enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), + enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[7] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, dx), enif_make_double(env, dy), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), + enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), + enif_make_double(env, dy), enif_make_atom(env, phase), enif_make_int64(env, ts_ms), + enif_make_uint64(env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(env, keys, vals, 7, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "drag"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "drag"), enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } -void mob_send_pinch(int handle, double scale, double velocity, const char* phase) { +void mob_send_pinch(int handle, double scale, double velocity, const char *phase) { int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, scale, 0, 16, 0.01)) return; + if (!phase_boundary && !mob_throttle_check_a(handle, scale, 0, 16, 0.01)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; unsigned long long seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "scale"), enif_make_atom(env, "velocity"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), + enif_make_atom(env, "scale"), enif_make_atom(env, "velocity"), enif_make_atom(env, "phase"), + enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[5] = { - enif_make_double(env, scale), enif_make_double(env, velocity), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), + enif_make_double(env, scale), enif_make_double(env, velocity), enif_make_atom(env, phase), + enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "pinch"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "pinch"), enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } -void mob_send_rotate(int handle, double degrees, double velocity, const char* phase) { +void mob_send_rotate(int handle, double degrees, double velocity, const char *phase) { int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, degrees, 0, 16, 1.0)) return; + if (!phase_boundary && !mob_throttle_check_a(handle, degrees, 0, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; unsigned long long seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { enif_make_atom(env, "degrees"), enif_make_atom(env, "velocity"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), + enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), + enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[5] = { - enif_make_double(env, degrees), enif_make_double(env, velocity), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), + enif_make_double(env, degrees), enif_make_double(env, velocity), enif_make_atom(env, phase), + enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "rotate"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "rotate"), enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } void mob_send_pointer_move(int handle, double x, double y) { - if (!mob_throttle_check_a(handle, x, y, 33, 4.0)) return; + if (!mob_throttle_check_a(handle, x, y, 33, 4.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; unsigned long long seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), + enif_make_atom(env, "x"), + enif_make_atom(env, "y"), + enif_make_atom(env, "ts"), + enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[4] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), + enif_make_double(env, x), + enif_make_double(env, y), + enif_make_int64(env, ts_ms), + enif_make_uint64(env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(env, keys, vals, 4, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "pointer_move"), - enif_make_copy(env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "pointer_move"), + enif_make_copy(env, tag), payload); enif_send(NULL, &pid, env, msg); enif_free_env(env); } // ── Batch 5 Tier 2: semantic single-fire scroll events ── -void mob_send_scroll_began(int handle) { send_event(handle, "scroll_began"); } -void mob_send_scroll_ended(int handle) { send_event(handle, "scroll_ended"); } -void mob_send_scroll_settled(int handle) { send_event(handle, "scroll_settled"); } -void mob_send_top_reached(int handle) { send_event(handle, "top_reached"); } -void mob_send_scrolled_past(int handle) { send_event(handle, "scrolled_past"); } +void mob_send_scroll_began(int handle) { + send_event(handle, "scroll_began"); +} +void mob_send_scroll_ended(int handle) { + send_event(handle, "scroll_ended"); +} +void mob_send_scroll_settled(int handle) { + send_event(handle, "scroll_settled"); +} +void mob_send_top_reached(int handle) { + send_event(handle, "top_reached"); +} +void mob_send_scrolled_past(int handle) { + send_event(handle, "scrolled_past"); +} // ── Back gesture sender ─────────────────────────────────────────────────────── // Called from beam_jni.c's nativeHandleBack JNI stub when the Android back @@ -587,12 +590,11 @@ void mob_send_scrolled_past(int handle) { send_event(handle, "scrolled_past"); // {:mob, :back} — Mob.Screen.handle_info/2 handles popping or exiting. void mob_handle_back(void) { - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "mob"), - enif_make_atom(env, "back")); + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); enif_send(NULL, &pid, env, msg); } enif_free_env(env); @@ -600,10 +602,10 @@ void mob_handle_back(void) { // ── JNI helpers ────────────────────────────────────────────────────────────── -static JNIEnv* get_jenv(int* attached) { - JNIEnv* env = NULL; +static JNIEnv *get_jenv(int *attached) { + JNIEnv *env = NULL; *attached = 0; - if ((*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6) == JNI_EDETACHED) { + if ((*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) { (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL); *attached = 1; } @@ -612,55 +614,71 @@ static JNIEnv* get_jenv(int* attached) { // ── Cache MobBridge class (called from mob_beam.c) ─────────────────────────── -void _mob_ui_cache_class_impl(JNIEnv* jenv, const char* bridge_class) { +void _mob_ui_cache_class_impl(JNIEnv *jenv, const char *bridge_class) { LOGI("mob_ui_cache_class: looking up %s", bridge_class); jclass cls = (*jenv)->FindClass(jenv, bridge_class); - if (!cls) { LOGE("mob_ui_cache_class: %s not found", bridge_class); return; } + if (!cls) { + LOGE("mob_ui_cache_class: %s not found", bridge_class); + return; + } Bridge.cls = (*jenv)->NewGlobalRef(jenv, cls); (*jenv)->DeleteLocalRef(jenv, cls); // Cache startup status methods now — they're needed before nif_load runs. // These are optional (older MobBridge versions may not have them); clear // any pending exception rather than aborting. - Bridge.set_startup_phase = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_phase) (*jenv)->ExceptionClear(jenv); - Bridge.set_startup_error = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_error) (*jenv)->ExceptionClear(jenv); + Bridge.set_startup_phase = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); + if (!Bridge.set_startup_phase) + (*jenv)->ExceptionClear(jenv); + Bridge.set_startup_error = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); + if (!Bridge.set_startup_error) + (*jenv)->ExceptionClear(jenv); LOGI("mob_ui_cache_class: %s cached OK", bridge_class); } -void mob_set_startup_phase(const char* phase) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_phase) return; - int att; JNIEnv* env = get_jenv(&att); +void mob_set_startup_phase(const char *phase) { + if (!g_jvm || !Bridge.cls || !Bridge.set_startup_phase) + return; + int att; + JNIEnv *env = get_jenv(&att); jstring js = (*env)->NewStringUTF(env, phase); (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_phase, js); (*env)->DeleteLocalRef(env, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); LOGI("startup: %s", phase); } -void mob_set_startup_error(const char* error) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_error) return; - int att; JNIEnv* env = get_jenv(&att); +void mob_set_startup_error(const char *error) { + if (!g_jvm || !Bridge.cls || !Bridge.set_startup_error) + return; + int att; + JNIEnv *env = get_jenv(&att); jstring js = (*env)->NewStringUTF(env, error); (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_error, js); (*env)->DeleteLocalRef(env, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); LOGE("startup ERROR: %s", error); } // ── Initialize bridge with Activity (called from mob_beam.c) ───────────────── -void _mob_bridge_init_activity(JNIEnv* env, jobject activity) { - if (!Bridge.cls) { LOGE("_mob_bridge_init_activity: Bridge.cls not cached"); return; } - jmethodID init = (*env)->GetStaticMethodID(env, Bridge.cls, "init", - "(Landroid/app/Activity;)V"); +void _mob_bridge_init_activity(JNIEnv *env, jobject activity) { + if (!Bridge.cls) { + LOGE("_mob_bridge_init_activity: Bridge.cls not cached"); + return; + } + jmethodID init = + (*env)->GetStaticMethodID(env, Bridge.cls, "init", "(Landroid/app/Activity;)V"); (*env)->CallStaticVoidMethod(env, Bridge.cls, init, activity); LOGI("_mob_bridge_init_activity: MobBridge.init called"); } // ── NIF: platform/0 ────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "android"); } @@ -669,27 +687,31 @@ static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // Returns :light if MobBridge.getColorScheme() isn't compiled into the app // (older projects). -static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.get_color_scheme) return enif_make_atom(env, "light"); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.get_color_scheme) + return enif_make_atom(env, "light"); + int att; + JNIEnv *jenv = get_jenv(&att); jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.get_color_scheme); ERL_NIF_TERM atom = enif_make_atom(env, "light"); if (result) { - const char* str = (*jenv)->GetStringUTFChars(jenv, result, NULL); + const char *str = (*jenv)->GetStringUTFChars(jenv, result, NULL); if (str) { - if (strcmp(str, "dark") == 0) atom = enif_make_atom(env, "dark"); + if (strcmp(str, "dark") == 0) + atom = enif_make_atom(env, "dark"); (*jenv)->ReleaseStringUTFChars(jenv, result, str); } (*jenv)->DeleteLocalRef(jenv, result); } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return atom; } // ── NIF: log/1 ─────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char buf[4096] = {0}; ErlNifBinary bin; if (enif_inspect_binary(env, argv[0], &bin)) { @@ -705,17 +727,20 @@ static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) // ── NIF: log/2 ─────────────────────────────────────────────────────────────── -static int atom_to_android_priority(ErlNifEnv* env, ERL_NIF_TERM level_atom) { +static int atom_to_android_priority(ErlNifEnv *env, ERL_NIF_TERM level_atom) { char level[16]; if (!enif_get_atom(env, level_atom, level, sizeof(level), ERL_NIF_LATIN1)) return ANDROID_LOG_INFO; - if (strcmp(level, "debug") == 0) return ANDROID_LOG_DEBUG; - if (strcmp(level, "warning") == 0) return ANDROID_LOG_WARN; - if (strcmp(level, "error") == 0) return ANDROID_LOG_ERROR; + if (strcmp(level, "debug") == 0) + return ANDROID_LOG_DEBUG; + if (strcmp(level, "warning") == 0) + return ANDROID_LOG_WARN; + if (strcmp(level, "error") == 0) + return ANDROID_LOG_ERROR; return ANDROID_LOG_INFO; } -static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char buf[4096] = {0}; int priority = atom_to_android_priority(env, argv[0]); ErlNifBinary bin; @@ -734,15 +759,16 @@ static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[] // Accepts a JSON binary and passes it to MobBridge.setRootJson(String) on the // Kotlin side. Compose state update is thread-safe — no main-thread hop needed. -static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); // Null-terminate for NewStringUTF - char* json = (char*)malloc(bin.size + 1); - if (!json) return enif_make_atom(env, "error"); + char *json = (char *)malloc(bin.size + 1); + if (!json) + return enif_make_atom(env, "error"); memcpy(json, bin.data, bin.size); json[bin.size] = 0; @@ -751,25 +777,27 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar char transition[16]; strncpy(transition, g_transition, sizeof(transition) - 1); transition[sizeof(transition) - 1] = 0; - strncpy(g_transition, "none", sizeof(g_transition)); // reset to none + strncpy(g_transition, "none", sizeof(g_transition)); // reset to none enif_mutex_unlock(tap_mutex); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jjson = (*jenv)->NewStringUTF(jenv, json); + int att; + JNIEnv *jenv = get_jenv(&att); + jstring jjson = (*jenv)->NewStringUTF(jenv, json); jstring jtransition = (*jenv)->NewStringUTF(jenv, transition); free(json); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.set_root, jjson, jtransition); (*jenv)->DeleteLocalRef(jenv, jjson); (*jenv)->DeleteLocalRef(jenv, jtransition); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: register_tap/1 ────────────────────────────────────────────────────── // Accepts pid (tag = :ok) or {pid, tag} (any Erlang term used as the tag). -static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; +static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; ERL_NIF_TERM tag_term; // Try plain pid first @@ -779,7 +807,7 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER } else { // Try {pid, tag} 2-tuple int arity; - const ERL_NIF_TERM* elems; + const ERL_NIF_TERM *elems; if (!enif_get_tuple(env, argv[0], &arity, &elems) || arity != 2) return enif_make_badarg(env); if (!enif_get_local_pid(env, elems[0], &pid)) @@ -793,9 +821,9 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER return enif_make_badarg(env); } int handle = tap_handle_next++; - tap_handles[handle].pid = pid; + tap_handles[handle].pid = pid; tap_handles[handle].tag_env = enif_alloc_env(); - tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); + tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); enif_mutex_unlock(tap_mutex); return enif_make_int(env, handle); @@ -803,7 +831,7 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER // ── NIF: clear_taps/0 ──────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); for (int i = 0; i < tap_handle_next; i++) { if (tap_handles[i].tag_env) { @@ -811,15 +839,15 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM tap_handles[i].tag_env = NULL; } // Reset throttle state — slots get reused across renders. - tap_handles[i].throttle_ms = 0; - tap_handles[i].debounce_ms = 0; + tap_handles[i].throttle_ms = 0; + tap_handles[i].debounce_ms = 0; tap_handles[i].delta_threshold = 0; - tap_handles[i].leading = 1; - tap_handles[i].trailing = 1; - tap_handles[i].last_emit_ns = 0; - tap_handles[i].last_x = 0; - tap_handles[i].last_y = 0; - tap_handles[i].seq = 0; + tap_handles[i].leading = 1; + tap_handles[i].trailing = 1; + tap_handles[i].last_emit_ns = 0; + tap_handles[i].last_x = 0; + tap_handles[i].last_y = 0; + tap_handles[i].seq = 0; } tap_handle_next = 0; enif_mutex_unlock(tap_mutex); @@ -830,10 +858,12 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM // Backgrounds the app via MobBridge.moveToBack() → activity.moveTaskToBack(true). // Called by Mob.Screen when the back gesture fires at the root of the nav stack. -static ERL_NIF_TERM nif_exit_app(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_exit_app(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.move_to_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } @@ -841,7 +871,7 @@ static ERL_NIF_TERM nif_exit_app(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // Stores the transition type atom (push/pop/reset/none) to be passed to // setRootJson on the next set_root call. Must be called before set_root. -static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); if (!enif_get_atom(env, argv[0], g_transition, sizeof(g_transition), ERL_NIF_LATIN1)) { enif_mutex_unlock(tap_mutex); @@ -854,67 +884,73 @@ static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_T // ── NIF: safe_area/0 ───────────────────────────────────────────────────────── // Returns {Top, Right, Bottom, Left} in dp via MobBridge.getSafeArea(). -static ERL_NIF_TERM nif_safe_area(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jfloatArray arr = (jfloatArray)(*jenv)->CallStaticObjectMethod( - jenv, Bridge.cls, Bridge.get_safe_area); +static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); + jfloatArray arr = + (jfloatArray)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.get_safe_area); float vals[4] = {0.0f, 0.0f, 0.0f, 0.0f}; if (arr) { (*jenv)->GetFloatArrayRegion(jenv, arr, 0, 4, vals); (*jenv)->DeleteLocalRef(jenv, arr); } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_tuple4(env, - enif_make_double(env, (double)vals[0]), - enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), - enif_make_double(env, (double)vals[3]) - ); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); + return enif_make_tuple4( + env, enif_make_double(env, (double)vals[0]), enif_make_double(env, (double)vals[1]), + enif_make_double(env, (double)vals[2]), enif_make_double(env, (double)vals[3])); } // ── NIF: haptic/1 ───────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_haptic(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char type[32] = {0}; enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtype = (*jenv)->NewStringUTF(jenv, type); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.haptic, jtype); (*jenv)->DeleteLocalRef(jenv, jtype); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); + char *text = (char *)malloc(bin.size + 1); + if (!text) + return enif_make_atom(env, "error"); memcpy(text, bin.data, bin.size); text[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtext = (*jenv)->NewStringUTF(jenv, text); free(text); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.clipboard_put, jtext); (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: clipboard_get/0 ────────────────────────────────────────────────────── // Returns {:ok, Binary} or :empty. -static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.clipboard_get); +static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); + jstring result = + (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.clipboard_get); ERL_NIF_TERM ret; if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); + const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -925,47 +961,54 @@ static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TE } else { ret = enif_make_atom(env, "empty"); } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ret; } // ── NIF: open_url/1 ─────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_open_url(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_open_url(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* url = (char*)malloc(bin.size + 1); - if (!url) return enif_make_atom(env, "error"); + char *url = (char *)malloc(bin.size + 1); + if (!url) + return enif_make_atom(env, "error"); memcpy(url, bin.data, bin.size); url[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jurl = (*jenv)->NewStringUTF(jenv, url); free(url); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.open_url, jurl); (*jenv)->DeleteLocalRef(jenv, jurl); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: share_text/1 ───────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_share_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); + char *text = (char *)malloc(bin.size + 1); + if (!text) + return enif_make_atom(env, "error"); memcpy(text, bin.data, bin.size); text[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtext = (*jenv)->NewStringUTF(jenv, text); free(text); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.share_text, jtext); (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } @@ -976,25 +1019,29 @@ static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ════════════════════════════════════════════════════════════════════════════ // Launch notification global (written by MobBridge.setLaunchNotification, read once) -static char* g_launch_notif_json = NULL; -static ErlNifMutex* g_launch_notif_mutex = NULL; +static char *g_launch_notif_json = NULL; +static ErlNifMutex *g_launch_notif_mutex = NULL; // Called from MobBridge.setLaunchNotification(json) -void mob_set_launch_notification(const char* json) { - if (!g_launch_notif_mutex) return; +void mob_set_launch_notification(const char *json) { + if (!g_launch_notif_mutex) + return; enif_mutex_lock(g_launch_notif_mutex); free(g_launch_notif_json); g_launch_notif_json = json ? strdup(json) : NULL; enif_mutex_unlock(g_launch_notif_mutex); } -static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!g_launch_notif_mutex) return enif_make_atom(env, "none"); +static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + if (!g_launch_notif_mutex) + return enif_make_atom(env, "none"); enif_mutex_lock(g_launch_notif_mutex); - char* json = g_launch_notif_json; + char *json = g_launch_notif_json; g_launch_notif_json = NULL; enif_mutex_unlock(g_launch_notif_mutex); - if (!json) return enif_make_atom(env, "none"); + if (!json) + return enif_make_atom(env, "none"); ErlNifBinary bin; enif_alloc_binary(strlen(json), &bin); memcpy(bin.data, json, strlen(json)); @@ -1003,29 +1050,36 @@ static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const } // Generic helper: call Kotlin static method(pid_long, string_arg) -static ERL_NIF_TERM call_bridge_pid_str(ErlNifEnv* env, jmethodID method, - ErlNifPid pid, const char* arg) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM call_bridge_pid_str(ErlNifEnv *env, jmethodID method, ErlNifPid pid, + const char *arg) { + int att; + JNIEnv *jenv = get_jenv(&att); jlong jpid; memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); jstring jarg = arg ? (*jenv)->NewStringUTF(jenv, arg) : NULL; (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, jarg); - if (jarg) (*jenv)->DeleteLocalRef(jenv, jarg); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (jarg) + (*jenv)->DeleteLocalRef(jenv, jarg); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM call_bridge_pid_str2(ErlNifEnv* env, jmethodID method, - ErlNifPid pid, const char* a1, const char* a2) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM call_bridge_pid_str2(ErlNifEnv *env, jmethodID method, ErlNifPid pid, + const char *a1, const char *a2) { + int att; + JNIEnv *jenv = get_jenv(&att); jlong jpid; memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); jstring j1 = a1 ? (*jenv)->NewStringUTF(jenv, a1) : NULL; jstring j2 = a2 ? (*jenv)->NewStringUTF(jenv, a2) : NULL; (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, j1, j2); - if (j1) (*jenv)->DeleteLocalRef(jenv, j1); - if (j2) (*jenv)->DeleteLocalRef(jenv, j2); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (j1) + (*jenv)->DeleteLocalRef(jenv, j1); + if (j2) + (*jenv)->DeleteLocalRef(jenv, j2); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } @@ -1039,7 +1093,7 @@ static ERL_NIF_TERM call_bridge_pid_str2(ErlNifEnv* env, jmethodID method, // // mob_nif_deliver_json(pid_long, json_cstr) — send pre-formed JSON event to pid // This is declared in mob_beam.h for Kotlin to call via JNI. -void mob_nif_deliver_json(jlong pid_long, const char* json_str) { +void mob_nif_deliver_json(jlong pid_long, const char *json_str) { // We don't send JSON to the BEAM — we need to build proper Erlang terms. // Instead, we use a set of typed delivery functions called from Kotlin. // See mob_beam.h for the full set. @@ -1055,95 +1109,92 @@ static ErlNifPid pid_from_long(jlong jpid) { return pid; } -void mob_deliver_atom2(jlong jpid, const char* a1, const char* a2) { +void mob_deliver_atom2(jlong jpid, const char *a1, const char *a2) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,a1), enif_make_atom(e,a2)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, a1), enif_make_atom(e, a2)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } -void mob_deliver_atom3(jlong jpid, const char* a1, const char* a2, const char* a3) { +void mob_deliver_atom3(jlong jpid, const char *a1, const char *a2, const char *a3) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,a1), enif_make_atom(e,a2), enif_make_atom(e,a3)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, a1), enif_make_atom(e, a2), enif_make_atom(e, a3)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } void mob_deliver_location(jlong jpid, double lat, double lon, double acc, double alt) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(e,"lat"), enif_make_atom(e,"lon"), - enif_make_atom(e,"accuracy"), enif_make_atom(e,"altitude") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(e,lat), enif_make_double(e,lon), - enif_make_double(e,acc), enif_make_double(e,alt) - }; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 4, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"location"), map); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM keys[4] = {enif_make_atom(e, "lat"), enif_make_atom(e, "lon"), + enif_make_atom(e, "accuracy"), enif_make_atom(e, "altitude")}; + ERL_NIF_TERM vals[4] = {enif_make_double(e, lat), enif_make_double(e, lon), + enif_make_double(e, acc), enif_make_double(e, alt)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 4, &map); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "location"), map); enif_send(NULL, &pid, e, msg); enif_free_env(e); } -void mob_deliver_motion(jlong jpid, double ax, double ay, double az, - double gx, double gy, double gz, long long ts) { +void mob_deliver_motion(jlong jpid, double ax, double ay, double az, double gx, double gy, + double gz, long long ts) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, - enif_make_double(e,ax), enif_make_double(e,ay), enif_make_double(e,az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, - enif_make_double(e,gx), enif_make_double(e,gy), enif_make_double(e,gz)); - ERL_NIF_TERM keys[3] = { - enif_make_atom(e,"accel"), enif_make_atom(e,"gyro"), enif_make_atom(e,"timestamp") - }; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e,ts)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"motion"), map); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), enif_make_double(e, ay), + enif_make_double(e, az)); + ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), enif_make_double(e, gy), + enif_make_double(e, gz)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "motion"), map); enif_send(NULL, &pid, e, msg); enif_free_env(e); } // Deliver a {:webview, tag, binary} message. When jpid==0, looks up :mob_screen. -static void deliver_webview_binary(jlong jpid, const char* tag, const char* utf8) { - ErlNifEnv* e = enif_alloc_env(); +static void deliver_webview_binary(jlong jpid, const char *tag, const char *utf8) { + ErlNifEnv *e = enif_alloc_env(); ErlNifPid pid; if (jpid != 0) { pid = pid_from_long(jpid); } else if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); return; + enif_free_env(e); + return; } size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, "webview"), - enif_make_atom(e, tag), - enif_make_binary(e, &bin)); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "webview"), enif_make_atom(e, tag), + enif_make_binary(e, &bin)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } -void mob_deliver_webview_message(jlong jpid, const char* json) { +void mob_deliver_webview_message(jlong jpid, const char *json) { deliver_webview_binary(jpid, "message", json); } -void mob_deliver_webview_blocked(jlong jpid, const char* url) { +void mob_deliver_webview_blocked(jlong jpid, const char *url) { deliver_webview_binary(jpid, "blocked", url); } -void mob_deliver_file_result(jlong jpid, const char* event, // "camera","photos","files","audio","scan" - const char* sub, // "photo","video","picked","recorded","result","cancelled" - const char* json_items) { // JSON array of item maps, or NULL for cancelled +void mob_deliver_file_result( + jlong jpid, const char *event, // "camera","photos","files","audio","scan" + const char *sub, // "photo","video","picked","recorded","result","cancelled" + const char *json_items) { // JSON array of item maps, or NULL for cancelled ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); + ErlNifEnv *e = enif_alloc_env(); ERL_NIF_TERM msg; if (!json_items || strcmp(json_items, "cancelled") == 0) { - msg = enif_make_tuple2(e, enif_make_atom(e,event), enif_make_atom(e,"cancelled")); + msg = enif_make_tuple2(e, enif_make_atom(e, event), enif_make_atom(e, "cancelled")); } else { // Parse JSON array of maps and build Erlang list // Simple approach: pass the raw JSON binary as a string; the BEAM can decode it if needed. @@ -1160,240 +1211,305 @@ void mob_deliver_file_result(jlong jpid, const char* event, // "camera","photos" // and have Mob.Screen decode it — but screen doesn't do that for file results. // Better: send as a tagged binary that Elixir wrappers decode. // We'll send {:mob_file_result, event, sub, json_binary} and add a handler. - ErlNifBinary eb; size_t el = strlen(event); enif_alloc_binary(el,&eb); memcpy(eb.data,event,el); - ErlNifBinary sb; size_t sl = strlen(sub); enif_alloc_binary(sl,&sb); memcpy(sb.data,sub,sl); - msg = enif_make_tuple4(e, - enif_make_atom(e,"mob_file_result"), - enif_make_binary(e,&eb), - enif_make_binary(e,&sb), - enif_make_binary(e,&jb)); + ErlNifBinary eb; + size_t el = strlen(event); + enif_alloc_binary(el, &eb); + memcpy(eb.data, event, el); + ErlNifBinary sb; + size_t sl = strlen(sub); + enif_alloc_binary(sl, &sb); + memcpy(sb.data, sub, sl); + msg = enif_make_tuple4(e, enif_make_atom(e, "mob_file_result"), enif_make_binary(e, &eb), + enif_make_binary(e, &sb), enif_make_binary(e, &jb)); } enif_send(NULL, &pid, e, msg); enif_free_env(e); } -void mob_deliver_push_token(jlong jpid, const char* token) { +void mob_deliver_push_token(jlong jpid, const char *token) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ErlNifBinary tb; size_t tl = strlen(token); enif_alloc_binary(tl,&tb); memcpy(tb.data,token,tl); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"push_token"), enif_make_atom(e,"android"), enif_make_binary(e,&tb)); + ErlNifEnv *e = enif_alloc_env(); + ErlNifBinary tb; + size_t tl = strlen(token); + enif_alloc_binary(tl, &tb); + memcpy(tb.data, token, tl); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "push_token"), + enif_make_atom(e, "android"), enif_make_binary(e, &tb)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } -void mob_deliver_notification(jlong jpid, const char* json) { +void mob_deliver_notification(jlong jpid, const char *json) { ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ErlNifBinary jb; size_t jl = strlen(json); enif_alloc_binary(jl,&jb); memcpy(jb.data,json,jl); - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e,"mob_launch_notification"), enif_make_binary(e,&jb)); + ErlNifEnv *e = enif_alloc_env(); + ErlNifBinary jb; + size_t jl = strlen(json); + enif_alloc_binary(jl, &jb); + memcpy(jb.data, json, jl); + ERL_NIF_TERM msg = + enif_make_tuple2(e, enif_make_atom(e, "mob_launch_notification"), enif_make_binary(e, &jb)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } // NIF implementations — thin wrappers that pass work to Kotlin -static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char cap[32]; enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char cap[32]; + enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1); + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.request_permission, pid, cap); } -static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); char reason[256] = "Authenticate"; - if (bin.size < sizeof(reason)) { memcpy(reason, bin.data, bin.size); reason[bin.size] = 0; } - ErlNifPid pid; enif_self(env, &pid); + if (bin.size < sizeof(reason)) { + memcpy(reason, bin.data, bin.size); + reason[bin.size] = 0; + } + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.biometric_authenticate, pid, reason); } -static ERL_NIF_TERM nif_location_get_once(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_location_get_once(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.location_get_once, pid, "balanced"); } -static ERL_NIF_TERM nif_location_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char acc[16] = "balanced"; enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_location_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char acc[16] = "balanced"; + enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.location_start, pid, acc); } -static ERL_NIF_TERM nif_location_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_location_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.location_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char qual[16] = "high"; enif_get_atom(env, argv[0], qual, sizeof(qual), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char qual[16] = "high"; + enif_get_atom(env, argv[0], qual, sizeof(qual), ERL_NIF_LATIN1); + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.camera_capture_photo, pid, qual); } -static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int max_dur = 60; enif_get_int(env, argv[0], &max_dur); - ErlNifPid pid; enif_self(env, &pid); - char dur_str[16]; snprintf(dur_str, sizeof(dur_str), "%d", max_dur); +static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int max_dur = 60; + enif_get_int(env, argv[0], &max_dur); + ErlNifPid pid; + enif_self(env, &pid); + char dur_str[16]; + snprintf(dur_str, sizeof(dur_str), "%d", max_dur); return call_bridge_pid_str(env, Bridge.camera_capture_video, pid, dur_str); } -static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + char *json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); + json[bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.camera_start_preview, pid, json); free(json); return result; } -static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.camera_stop_preview); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_photos_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int max = 1; enif_get_int(env, argv[0], &max); - ErlNifPid pid; enif_self(env, &pid); - char max_str[16]; snprintf(max_str, sizeof(max_str), "%d", max); +static ERL_NIF_TERM nif_photos_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int max = 1; + enif_get_int(env, argv[0], &max); + ErlNifPid pid; + enif_self(env, &pid); + char max_str[16]; + snprintf(max_str, sizeof(max_str), "%d", max); return call_bridge_pid_str(env, Bridge.photos_pick, pid, max_str); } -static ERL_NIF_TERM nif_files_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_files_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + char *json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); + json[bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.files_pick, pid, json); free(json); return result; } -static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + char *json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); + json[bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.audio_start_recording, pid, json); free(json); return result; } -static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_recording); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_play(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_play(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary path_bin, opts_bin; if (!enif_inspect_binary(env, argv[0], &path_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &opts_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) return enif_make_badarg(env); - char* path = malloc(path_bin.size + 1); - memcpy(path, path_bin.data, path_bin.size); path[path_bin.size] = 0; - char* opts = malloc(opts_bin.size + 1); - memcpy(opts, opts_bin.data, opts_bin.size); opts[opts_bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) + return enif_make_badarg(env); + char *path = malloc(path_bin.size + 1); + memcpy(path, path_bin.data, path_bin.size); + path[path_bin.size] = 0; + char *opts = malloc(opts_bin.size + 1); + memcpy(opts, opts_bin.data, opts_bin.size); + opts[opts_bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str2(env, Bridge.audio_play, pid, path, opts); - free(path); free(opts); + free(path); + free(opts); return result; } -static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_playback); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double vol = 1.0; enif_get_double(env, argv[0], &vol); - char vol_str[32]; snprintf(vol_str, sizeof(vol_str), "%.6f", vol); - int att; JNIEnv* jenv = get_jenv(&att); + char vol_str[32]; + snprintf(vol_str, sizeof(vol_str), "%.6f", vol); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jvol = (*jenv)->NewStringUTF(jenv, vol_str); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_set_volume, jvol); (*jenv)->DeleteLocalRef(jenv, jvol); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_motion_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int interval_ms = 100; enif_get_int(env, argv[1], &interval_ms); - char interval_str[16]; snprintf(interval_str, sizeof(interval_str), "%d", interval_ms); - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int interval_ms = 100; + enif_get_int(env, argv[1], &interval_ms); + char interval_str[16]; + snprintf(interval_str, sizeof(interval_str), "%d", interval_ms); + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.motion_start, pid, interval_str); } -static ERL_NIF_TERM nif_motion_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_motion_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.motion_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + char *json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); + json[bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.scanner_scan, pid, json); free(json); return result; } -static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); + char *json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); + json[bin.size] = 0; + ErlNifPid pid; + enif_self(env, &pid); ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.notify_schedule, pid, json); free(json); return result; } -static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); char nid[256] = ""; - if (bin.size < sizeof(nid)) { memcpy(nid, bin.data, bin.size); nid[bin.size] = 0; } - int att; JNIEnv* jenv = get_jenv(&att); + if (bin.size < sizeof(nid)) { + memcpy(nid, bin.data, bin.size); + nid[bin.size] = 0; + } + int att; + JNIEnv *jenv = get_jenv(&att); jstring js = (*jenv)->NewStringUTF(jenv, nid); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.notify_cancel, js); (*jenv)->DeleteLocalRef(jenv, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); return call_bridge_pid_str(env, Bridge.notify_register_push, pid, NULL); } @@ -1410,10 +1526,12 @@ static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL // - Coordinates in dp (density-independent pixels), matching iOS convention // Helper: jstring → ERL_NIF_TERM binary (UTF-8). Deletes local ref. -static ERL_NIF_TERM jstring_to_bin(ErlNifEnv* env, JNIEnv* jenv, jstring js) { - if (!js) return enif_make_atom(env, "nil"); - const char* utf = (*jenv)->GetStringUTFChars(jenv, js, NULL); - if (!utf) return enif_make_atom(env, "nil"); +static ERL_NIF_TERM jstring_to_bin(ErlNifEnv *env, JNIEnv *jenv, jstring js) { + if (!js) + return enif_make_atom(env, "nil"); + const char *utf = (*jenv)->GetStringUTFChars(jenv, js, NULL); + if (!utf) + return enif_make_atom(env, "nil"); size_t len = strlen(utf); ErlNifBinary bin; enif_alloc_binary(len, &bin); @@ -1424,7 +1542,7 @@ static ERL_NIF_TERM jstring_to_bin(ErlNifEnv* env, JNIEnv* jenv, jstring js) { } // Helper: make a binary term from a C string (does NOT delete jstring). -static ERL_NIF_TERM cstr_to_bin(ErlNifEnv* env, const char* s, size_t len) { +static ERL_NIF_TERM cstr_to_bin(ErlNifEnv *env, const char *s, size_t len) { ErlNifBinary bin; enif_alloc_binary(len, &bin); memcpy(bin.data, s, len); @@ -1436,23 +1554,26 @@ static ERL_NIF_TERM cstr_to_bin(ErlNifEnv* env, const char* s, size_t len) { // Calls MobBridge.uiTree() which returns a newline-separated string: // type|label|value|x|y|w|h\n... // Parses that into a list of 4-tuples matching the iOS ui_tree format. -static ERL_NIF_TERM nif_ui_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); +static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.ui_tree) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); if (!jresult) { - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_list(env, 0); } - const char* raw = (*jenv)->GetStringUTFChars(jenv, jresult, NULL); + const char *raw = (*jenv)->GetStringUTFChars(jenv, jresult, NULL); ERL_NIF_TERM list = enif_make_list(env, 0); // Parse lines in reverse (we'll reverse the list at the end) // Format per line: type|label|value|x|y|w|h - const char* p = raw; + const char *p = raw; // Collect all lines into a temp array first (we build list in reverse for efficiency) // Simple approach: walk forward, build list, reverse at end ERL_NIF_TERM items[512]; @@ -1460,52 +1581,62 @@ static ERL_NIF_TERM nif_ui_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM arg while (*p && count < 512) { // Find end of line - const char* nl = strchr(p, '\n'); - if (!nl) break; + const char *nl = strchr(p, '\n'); + if (!nl) + break; size_t line_len = nl - p; char line[512]; - if (line_len >= sizeof(line)) { p = nl + 1; continue; } + if (line_len >= sizeof(line)) { + p = nl + 1; + continue; + } memcpy(line, p, line_len); line[line_len] = 0; p = nl + 1; // Split on '|': type, label, value, x, y, w, h - char* fields[7]; - int nf = 0; - char* tok = line; + char *fields[7]; + int nf = 0; + char *tok = line; for (int i = 0; i < 7; i++) { fields[i] = tok; - char* sep = (i < 6) ? strchr(tok, '|') : NULL; - if (sep) { *sep = 0; tok = sep + 1; nf++; } - else { nf = i + 1; break; } + char *sep = (i < 6) ? strchr(tok, '|') : NULL; + if (sep) { + *sep = 0; + tok = sep + 1; + nf++; + } else { + nf = i + 1; + break; + } } - if (nf < 7) continue; + if (nf < 7) + continue; double x = atof(fields[3]); double y = atof(fields[4]); double w = atof(fields[5]); double h = atof(fields[6]); - ERL_NIF_TERM frame = enif_make_tuple4(env, - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, w), enif_make_double(env, h)); + ERL_NIF_TERM frame = + enif_make_tuple4(env, enif_make_double(env, x), enif_make_double(env, y), + enif_make_double(env, w), enif_make_double(env, h)); // label and value: non-empty → binary, empty → atom nil size_t llen = strlen(fields[1]); size_t vlen = strlen(fields[2]); - ERL_NIF_TERM label = llen > 0 ? cstr_to_bin(env, fields[1], llen) - : enif_make_atom(env, "nil"); - ERL_NIF_TERM value = vlen > 0 ? cstr_to_bin(env, fields[2], vlen) - : enif_make_atom(env, "nil"); - - items[count++] = enif_make_tuple4(env, - enif_make_atom(env, fields[0]), - label, value, frame); + ERL_NIF_TERM label = + llen > 0 ? cstr_to_bin(env, fields[1], llen) : enif_make_atom(env, "nil"); + ERL_NIF_TERM value = + vlen > 0 ? cstr_to_bin(env, fields[2], vlen) : enif_make_atom(env, "nil"); + + items[count++] = enif_make_tuple4(env, enif_make_atom(env, fields[0]), label, value, frame); } (*jenv)->ReleaseStringUTFChars(jenv, jresult, raw); (*jenv)->DeleteLocalRef(jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); // Build list from items array (forward order) list = enif_make_list(env, 0); @@ -1523,13 +1654,17 @@ static ERL_NIF_TERM nif_ui_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM arg // The JSON is parsed by Mob.Test.tree/1 on the Erlang side (jason decode is fast // and avoids hand-rolling a JSON tokenizer in C). Returns {:error, :not_loaded} // if MobBridge.uiViewTree() isn't present (early adopter apps without registry). -static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_view_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_view_tree); +static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.ui_view_tree) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); + int att; + JNIEnv *jenv = get_jenv(&att); + jstring jresult = + (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_view_tree); ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return result; } @@ -1544,48 +1679,37 @@ static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv* env, int argc, const ERL_NIF_TER // brevity but the Kotlin side should send it once added to the array. // // Falls back to safe_area-only info if screenInfo() isn't bound (older bridges). -static ERL_NIF_TERM nif_screen_info(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - float vals[7] = {0}; // w, h, scale, top, bottom, left, right +static ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); + float vals[7] = {0}; // w, h, scale, top, bottom, left, right if (Bridge.screen_info) { - jfloatArray arr = (jfloatArray)(*jenv)->CallStaticObjectMethod( - jenv, Bridge.cls, Bridge.screen_info); + jfloatArray arr = + (jfloatArray)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.screen_info); if (arr) { jsize len = (*jenv)->GetArrayLength(jenv, arr); - if (len > 7) len = 7; + if (len > 7) + len = 7; (*jenv)->GetFloatArrayRegion(jenv, arr, 0, len, vals); (*jenv)->DeleteLocalRef(jenv, arr); } } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); - ERL_NIF_TERM sa_keys[4] = { - enif_make_atom(env, "top"), - enif_make_atom(env, "bottom"), - enif_make_atom(env, "left"), - enif_make_atom(env, "right") - }; + ERL_NIF_TERM sa_keys[4] = {enif_make_atom(env, "top"), enif_make_atom(env, "bottom"), + enif_make_atom(env, "left"), enif_make_atom(env, "right")}; ERL_NIF_TERM sa_vals[4] = { - enif_make_double(env, (double)vals[3]), - enif_make_double(env, (double)vals[4]), - enif_make_double(env, (double)vals[5]), - enif_make_double(env, (double)vals[6]) - }; + enif_make_double(env, (double)vals[3]), enif_make_double(env, (double)vals[4]), + enif_make_double(env, (double)vals[5]), enif_make_double(env, (double)vals[6])}; ERL_NIF_TERM safe_area; enif_make_map_from_arrays(env, sa_keys, sa_vals, 4, &safe_area); - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "width"), - enif_make_atom(env, "height"), - enif_make_atom(env, "scale"), - enif_make_atom(env, "safe_area") - }; - ERL_NIF_TERM vvals[4] = { - enif_make_double(env, (double)vals[0]), - enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), - safe_area - }; + ERL_NIF_TERM keys[4] = {enif_make_atom(env, "width"), enif_make_atom(env, "height"), + enif_make_atom(env, "scale"), enif_make_atom(env, "safe_area")}; + ERL_NIF_TERM vvals[4] = {enif_make_double(env, (double)vals[0]), + enif_make_double(env, (double)vals[1]), + enif_make_double(env, (double)vals[2]), safe_area}; ERL_NIF_TERM result; enif_make_map_from_arrays(env, keys, vvals, 4, &result); return result; @@ -1597,237 +1721,325 @@ static ERL_NIF_TERM nif_screen_info(ErlNifEnv* env, int argc, const ERL_NIF_TERM // implementation) is queued under WireTap (see future_developments.md). // Return a clear error so callers get `{:error, :not_supported_on_android}` // instead of an `:undef` crash. -static ERL_NIF_TERM nif_ax_action(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); +static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported_on_android")); } -static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); +static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported_on_android")); } // nif_ui_debug/0 — returns raw uiTree string as a binary (for debugging) -static ERL_NIF_TERM nif_ui_debug(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.ui_tree) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return result; } // nif_tap/1 — tap by accessibility label binary -static ERL_NIF_TERM nif_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_by_label) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); +static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.tap_by_label) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* label = (char*)malloc(bin.size + 1); - if (!label) return enif_make_atom(env, "error"); + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char *label = (char *)malloc(bin.size + 1); + if (!label) + return enif_make_atom(env, "error"); memcpy(label, bin.data, bin.size); label[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jlabel = (*jenv)->NewStringUTF(jenv, label); free(label); jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_by_label, jlabel); (*jenv)->DeleteLocalRef(jenv, jlabel); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_element_with_label")); + enif_make_atom(env, "no_element_with_label")); } // nif_tap_xy/2 — tap at (x, y) dp coordinates -static ERL_NIF_TERM nif_tap_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); +static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.tap_xy) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); double x, y; - if (!enif_get_double(env, argv[0], &x)) { int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; } - if (!enif_get_double(env, argv[1], &y)) { int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; } + if (!enif_get_double(env, argv[0], &x)) { + int ix; + if (!enif_get_int(env, argv[0], &ix)) + return enif_make_badarg(env); + x = ix; + } + if (!enif_get_double(env, argv[1], &y)) { + int iy; + if (!enif_get_int(env, argv[1], &iy)) + return enif_make_badarg(env); + y = iy; + } - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_xy, (jfloat)x, (jfloat)y); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + int att; + JNIEnv *jenv = get_jenv(&att); + jboolean ok = + (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_xy, (jfloat)x, (jfloat)y); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); + enif_make_atom(env, "dispatch_failed")); } // nif_type_text/1 — type text into the focused view -static ERL_NIF_TERM nif_type_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.type_text) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); +static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.type_text) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char *text = (char *)malloc(bin.size + 1); + if (!text) + return enif_make_atom(env, "error"); memcpy(text, bin.data, bin.size); text[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtext = (*jenv)->NewStringUTF(jenv, text); free(text); jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.type_text, jtext); (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + enif_make_atom(env, "no_first_responder")); } // nif_delete_backward/0 — delete one character backward -static ERL_NIF_TERM nif_delete_backward(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.delete_backward) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.delete_backward) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); + int att; + JNIEnv *jenv = get_jenv(&att); jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.delete_backward); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + enif_make_atom(env, "no_first_responder")); } // nif_key_press/1 — not yet implemented on Android (no KeyCharacterMap lookup) -static ERL_NIF_TERM nif_key_press(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_implemented")); +static ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_implemented")); } // nif_clear_text/0 — select-all + delete in the focused view -static ERL_NIF_TERM nif_clear_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.clear_text) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.clear_text) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); + int att; + JNIEnv *jenv = get_jenv(&att); jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.clear_text); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + enif_make_atom(env, "no_first_responder")); } // nif_long_press_xy/3 — long press at (x, y) for duration_ms milliseconds -static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.long_press_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - double x, y; int dur; - if (!enif_get_double(env, argv[0], &x)) { int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; } - if (!enif_get_double(env, argv[1], &y)) { int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; } - if (!enif_get_int(env, argv[2], &dur)) return enif_make_badarg(env); - - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.long_press_xy) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); + double x, y; + int dur; + if (!enif_get_double(env, argv[0], &x)) { + int ix; + if (!enif_get_int(env, argv[0], &ix)) + return enif_make_badarg(env); + x = ix; + } + if (!enif_get_double(env, argv[1], &y)) { + int iy; + if (!enif_get_int(env, argv[1], &iy)) + return enif_make_badarg(env); + y = iy; + } + if (!enif_get_int(env, argv[2], &dur)) + return enif_make_badarg(env); + + int att; + JNIEnv *jenv = get_jenv(&att); jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.long_press_xy, - (jfloat)x, (jfloat)y, (jlong)dur); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + (jfloat)x, (jfloat)y, (jlong)dur); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); + enif_make_atom(env, "dispatch_failed")); } // nif_swipe_xy/4 — swipe from (x1,y1) to (x2,y2) in dp -static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.swipe_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); +static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (!Bridge.swipe_xy) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_loaded")); double x1, y1, x2, y2; - if (!enif_get_double(env, argv[0], &x1)) { int i; if (!enif_get_int(env, argv[0], &i)) return enif_make_badarg(env); x1 = i; } - if (!enif_get_double(env, argv[1], &y1)) { int i; if (!enif_get_int(env, argv[1], &i)) return enif_make_badarg(env); y1 = i; } - if (!enif_get_double(env, argv[2], &x2)) { int i; if (!enif_get_int(env, argv[2], &i)) return enif_make_badarg(env); x2 = i; } - if (!enif_get_double(env, argv[3], &y2)) { int i; if (!enif_get_int(env, argv[3], &i)) return enif_make_badarg(env); y2 = i; } - - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.swipe_xy, - (jfloat)x1, (jfloat)y1, (jfloat)x2, (jfloat)y2); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (!enif_get_double(env, argv[0], &x1)) { + int i; + if (!enif_get_int(env, argv[0], &i)) + return enif_make_badarg(env); + x1 = i; + } + if (!enif_get_double(env, argv[1], &y1)) { + int i; + if (!enif_get_int(env, argv[1], &i)) + return enif_make_badarg(env); + y1 = i; + } + if (!enif_get_double(env, argv[2], &x2)) { + int i; + if (!enif_get_int(env, argv[2], &i)) + return enif_make_badarg(env); + x2 = i; + } + if (!enif_get_double(env, argv[3], &y2)) { + int i; + if (!enif_get_int(env, argv[3], &i)) + return enif_make_badarg(env); + y2 = i; + } + + int att; + JNIEnv *jenv = get_jenv(&att); + jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.swipe_xy, (jfloat)x1, + (jfloat)y1, (jfloat)x2, (jfloat)y2); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ok ? enif_make_atom(env, "ok") : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); + enif_make_atom(env, "dispatch_failed")); } // ── Storage ─────────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_storage_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char loc[32]; enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char loc[32]; + enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jloc = (*jenv)->NewStringUTF(jenv, loc); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.storage_dir, jloc); + jstring result = + (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.storage_dir, jloc); (*jenv)->DeleteLocalRef(jenv, jloc); ERL_NIF_TERM ret; if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); + const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); + ErlNifBinary bin; + size_t len = strlen(utf8); + enif_alloc_binary(len, &bin); + memcpy(bin.data, utf8, len); (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); (*jenv)->DeleteLocalRef(jenv, result); ret = enif_make_binary(env, &bin); } else { ret = enif_make_atom(env, "nil"); } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ret; } -static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* path = malloc(bin.size + 1); - memcpy(path, bin.data, bin.size); path[bin.size] = 0; - char type[16] = "auto"; enif_get_atom(env, argv[1], type, sizeof(type), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str2(env, Bridge.storage_save_to_media_store, pid, path, type); + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char *path = malloc(bin.size + 1); + memcpy(path, bin.data, bin.size); + path[bin.size] = 0; + char type[16] = "auto"; + enif_get_atom(env, argv[1], type, sizeof(type), ERL_NIF_LATIN1); + ErlNifPid pid; + enif_self(env, &pid); + ERL_NIF_TERM result = + call_bridge_pid_str2(env, Bridge.storage_save_to_media_store, pid, path, type); free(path); return result; } -static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char type[32] = {0}; enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + char type[32] = {0}; + enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtype = (*jenv)->NewStringUTF(jenv, type); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, - Bridge.storage_external_files_dir, jtype); + jstring result = (jstring)(*jenv)->CallStaticObjectMethod( + jenv, Bridge.cls, Bridge.storage_external_files_dir, jtype); (*jenv)->DeleteLocalRef(jenv, jtype); ERL_NIF_TERM ret; if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); + const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); + ErlNifBinary bin; + size_t len = strlen(utf8); + enif_alloc_binary(len, &bin); + memcpy(bin.data, utf8, len); (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); (*jenv)->DeleteLocalRef(jenv, result); ret = enif_make_binary(env, &bin); } else { ret = enif_make_atom(env, "nil"); } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return ret; } -static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_supported")); +static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported")); } // ── WebView ──────────────────────────────────────────────────────────────────── // ── Alert delivery (called from beam_jni.c when a dialog button is tapped) ── -void mob_deliver_alert_action(const char* action) { - ErlNifEnv* e = enif_alloc_env(); +void mob_deliver_alert_action(const char *action) { + ErlNifEnv *e = enif_alloc_env(); ErlNifPid pid; if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); return; + enif_free_env(e); + return; } - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e, "alert"), - enif_make_atom(e, action)); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "alert"), enif_make_atom(e, action)); enif_send(NULL, &pid, e, msg); enif_free_env(e); } // ── NIF: alert_show/3 ───────────────────────────────────────────────────── -static ERL_NIF_TERM nif_alert_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, msg_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) @@ -1839,34 +2051,38 @@ static ERL_NIF_TERM nif_alert_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) return enif_make_badarg(env); - char* title = malloc(title_bin.size + 1); + char *title = malloc(title_bin.size + 1); memcpy(title, title_bin.data, title_bin.size); title[title_bin.size] = '\0'; - char* message = malloc(msg_bin.size + 1); + char *message = malloc(msg_bin.size + 1); memcpy(message, msg_bin.data, msg_bin.size); message[msg_bin.size] = '\0'; - char* btns = malloc(btns_bin.size + 1); + char *btns = malloc(btns_bin.size + 1); memcpy(btns, btns_bin.data, btns_bin.size); btns[btns_bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtitle = (*jenv)->NewStringUTF(jenv, title); + int att; + JNIEnv *jenv = get_jenv(&att); + jstring jtitle = (*jenv)->NewStringUTF(jenv, title); jstring jmessage = (*jenv)->NewStringUTF(jenv, message); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); free(message); free(btns); + jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); + free(title); + free(message); + free(btns); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.alert_show, jtitle, jmessage, jbtns); (*jenv)->DeleteLocalRef(jenv, jtitle); (*jenv)->DeleteLocalRef(jenv, jmessage); (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: action_sheet_show/2 ────────────────────────────────────────────── -static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) @@ -1875,28 +2091,31 @@ static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv* env, int argc, const ERL_NI !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) return enif_make_badarg(env); - char* title = malloc(title_bin.size + 1); + char *title = malloc(title_bin.size + 1); memcpy(title, title_bin.data, title_bin.size); title[title_bin.size] = '\0'; - char* btns = malloc(btns_bin.size + 1); + char *btns = malloc(btns_bin.size + 1); memcpy(btns, btns_bin.data, btns_bin.size); btns[btns_bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jtitle = (*jenv)->NewStringUTF(jenv, title); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); free(btns); + jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); + free(title); + free(btns); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.action_sheet_show, jtitle, jbtns); (*jenv)->DeleteLocalRef(jenv, jtitle); (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── NIF: toast_show/2 ──────────────────────────────────────────────────── -static ERL_NIF_TERM nif_toast_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_toast_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary msg_bin; char dur[8] = "short"; if (!enif_inspect_binary(env, argv[0], &msg_bin) && @@ -1904,72 +2123,83 @@ static ERL_NIF_TERM nif_toast_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM return enif_make_badarg(env); enif_get_atom(env, argv[1], dur, sizeof(dur), ERL_NIF_LATIN1); - char* msg = malloc(msg_bin.size + 1); + char *msg = malloc(msg_bin.size + 1); memcpy(msg, msg_bin.data, msg_bin.size); msg[msg_bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jmsg = (*jenv)->NewStringUTF(jenv, msg); jstring jdur = (*jenv)->NewStringUTF(jenv, dur); free(msg); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.toast_show, jmsg, jdur); (*jenv)->DeleteLocalRef(jenv, jmsg); (*jenv)->DeleteLocalRef(jenv, jdur); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* code = malloc(bin.size + 1); + char *code = malloc(bin.size + 1); memcpy(code, bin.data, bin.size); code[bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jcode = (*jenv)->NewStringUTF(jenv, code); free(code); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_eval_js, jcode); (*jenv)->DeleteLocalRef(jenv, jcode); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* json = malloc(bin.size + 1); + char *json = malloc(bin.size + 1); memcpy(json, bin.data, bin.size); json[bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); + int att; + JNIEnv *jenv = get_jenv(&att); jstring jjson = (*jenv)->NewStringUTF(jenv, json); free(json); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_post_message, jjson); (*jenv)->DeleteLocalRef(jenv, jjson); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jboolean result = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.webview_can_go_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); +static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); + jboolean result = + (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.webview_can_go_back); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, result ? "true" : "false"); } -static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_go_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } // ── Native view component NIFs ──────────────────────────────────────────────── -static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); @@ -1977,7 +2207,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N enif_mutex_lock(component_mutex); for (int i = 0; i < MAX_COMPONENT_HANDLES; i++) { if (!component_handles[i].active) { - component_handles[i].pid = pid; + component_handles[i].pid = pid; component_handles[i].active = 1; enif_mutex_unlock(component_mutex); return enif_make_int(env, i); @@ -1987,7 +2217,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N return enif_make_badarg(env); } -static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { int handle; if (!enif_get_int(env, argv[0], &handle) || handle < 0 || handle >= MAX_COMPONENT_HANDLES) return enif_make_badarg(env); @@ -2000,17 +2230,21 @@ static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL // ── NIF: background_keep_alive/0, background_stop/0 ───────────────────────── -static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_keep_alive); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); +static ERL_NIF_TERM nif_background_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int att; + JNIEnv *jenv = get_jenv(&att); (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); return enif_make_atom(env, "ok"); } @@ -2024,16 +2258,15 @@ static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_ // reasonable defaults. static ErlNifPid g_device_dispatcher_pid; -static int g_device_dispatcher_set = 0; +static int g_device_dispatcher_set = 0; static void mob_device_send_atom_payload_android(const char *tag, const char *atom_name, const char *payload_atom_str) { - if (!g_device_dispatcher_set) return; + if (!g_device_dispatcher_set) + return; ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name), - enif_make_atom(e, payload_atom_str)); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name), + enif_make_atom(e, payload_atom_str)); enif_send(NULL, &g_device_dispatcher_pid, e, msg); enif_free_env(e); } @@ -2042,46 +2275,46 @@ static void mob_device_send_atom_payload_android(const char *tag, const char *at // stub when MainActivity.onConfigurationChanged sees a uiMode flip. // `scheme` must be "light" or "dark". void mob_send_color_scheme_changed(const char *scheme) { - if (!scheme) return; + if (!scheme) + return; mob_device_send_atom_payload_android("mob_device", "color_scheme_changed", scheme); } -static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); + if (!enif_get_local_pid(env, argv[0], &pid)) + return enif_make_badarg(env); g_device_dispatcher_pid = pid; g_device_dispatcher_set = 1; return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): query BatteryManager. For now, unknown / -1. - return enif_make_tuple2(env, - enif_make_atom(env, "unknown"), - enif_make_int(env, -1)); + return enif_make_tuple2(env, enif_make_atom(env, "unknown"), enif_make_int(env, -1)); } -static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): query PowerManager.getCurrentThermalStatus() (API 29+). return enif_make_atom(env, "nominal"); } -static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): query PowerManager.isPowerSaveMode(). return enif_make_atom(env, "false"); } -static ERL_NIF_TERM nif_device_foreground(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_foreground(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): track via ProcessLifecycleOwner. return enif_make_atom(env, "true"); } -static ERL_NIF_TERM nif_device_os_version(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_os_version(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): Build.VERSION.RELEASE via JNI. return enif_make_string(env, "", ERL_NIF_LATIN1); } -static ERL_NIF_TERM nif_device_model(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_device_model(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // TODO(android): Build.MODEL via JNI. return enif_make_string(env, "Android", ERL_NIF_LATIN1); } @@ -2096,103 +2329,122 @@ static ERL_NIF_TERM nif_device_model(ErlNifEnv* env, int argc, const ERL_NIF_TER // hand off to the UI thread quickly and don't need dirty dispatch overhead. static ErlNifFunc nif_funcs[] = { // ── Test harness first (matches iOS nif_funcs[] ordering convention) ────── - {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ax_action", 2, nif_ax_action, 0}, - {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, - {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"screen_info", 0, nif_screen_info, 0}, - {"tap", 1, nif_tap, 0}, - {"tap_xy", 2, nif_tap_xy, 0}, - {"type_text", 1, nif_type_text, 0}, - {"delete_backward", 0, nif_delete_backward, 0}, - {"key_press", 1, nif_key_press, 0}, - {"clear_text", 0, nif_clear_text, 0}, - {"long_press_xy", 3, nif_long_press_xy, 0}, - {"swipe_xy", 4, nif_swipe_xy, 0}, + {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ax_action", 2, nif_ax_action, 0}, + {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, + {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"screen_info", 0, nif_screen_info, 0}, + {"tap", 1, nif_tap, 0}, + {"tap_xy", 2, nif_tap_xy, 0}, + {"type_text", 1, nif_type_text, 0}, + {"delete_backward", 0, nif_delete_backward, 0}, + {"key_press", 1, nif_key_press, 0}, + {"clear_text", 0, nif_clear_text, 0}, + {"long_press_xy", 3, nif_long_press_xy, 0}, + {"swipe_xy", 4, nif_swipe_xy, 0}, // ── Core mob functions ──────────────────────────────────────────────────── - {"platform", 0, nif_platform, 0}, - {"color_scheme", 0, nif_color_scheme, 0}, - {"log", 1, nif_log, 0}, - {"log", 2, nif_log2, 0}, + {"platform", 0, nif_platform, 0}, + {"color_scheme", 0, nif_color_scheme, 0}, + {"log", 1, nif_log, 0}, + {"log", 2, nif_log2, 0}, {"set_transition", 1, nif_set_transition, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"register_tap", 1, nif_register_tap, 0}, - {"clear_taps", 0, nif_clear_taps, 0}, - {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, - {"haptic", 1, nif_haptic, 0}, - {"clipboard_put", 1, nif_clipboard_put, 0}, - {"clipboard_get", 0, nif_clipboard_get, 0}, - {"share_text", 1, nif_share_text, 0}, - {"open_url", 1, nif_open_url, 0}, - {"request_permission", 1, nif_request_permission, 0}, - {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, - {"location_get_once", 0, nif_location_get_once, 0}, - {"location_start", 1, nif_location_start, 0}, - {"location_stop", 0, nif_location_stop, 0}, - {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, - {"camera_capture_video", 1, nif_camera_capture_video, 0}, - {"camera_start_preview", 1, nif_camera_start_preview, 0}, - {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, - {"photos_pick", 2, nif_photos_pick, 0}, - {"files_pick", 1, nif_files_pick, 0}, - {"audio_start_recording", 1, nif_audio_start_recording, 0}, - {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, - {"audio_play", 2, nif_audio_play, 0}, - {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, - {"audio_set_volume", 1, nif_audio_set_volume, 0}, - {"motion_start", 2, nif_motion_start, 0}, - {"motion_stop", 0, nif_motion_stop, 0}, - {"scanner_scan", 1, nif_scanner_scan, 0}, - {"notify_schedule", 1, nif_notify_schedule, 0}, - {"notify_cancel", 1, nif_notify_cancel, 0}, - {"notify_register_push", 0, nif_notify_register_push, 0}, - {"take_launch_notification", 0, nif_take_launch_notification, 0}, - {"storage_dir", 1, nif_storage_dir, 0}, - {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, - {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, - {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, - {"alert_show", 3, nif_alert_show, 0}, - {"action_sheet_show", 2, nif_action_sheet_show, 0}, - {"toast_show", 2, nif_toast_show, 0}, - {"webview_eval_js", 1, nif_webview_eval_js, 0}, - {"webview_post_message",1, nif_webview_post_message,0}, + {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"register_tap", 1, nif_register_tap, 0}, + {"clear_taps", 0, nif_clear_taps, 0}, + {"exit_app", 0, nif_exit_app, 0}, + {"safe_area", 0, nif_safe_area, 0}, + {"haptic", 1, nif_haptic, 0}, + {"clipboard_put", 1, nif_clipboard_put, 0}, + {"clipboard_get", 0, nif_clipboard_get, 0}, + {"share_text", 1, nif_share_text, 0}, + {"open_url", 1, nif_open_url, 0}, + {"request_permission", 1, nif_request_permission, 0}, + {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, + {"location_get_once", 0, nif_location_get_once, 0}, + {"location_start", 1, nif_location_start, 0}, + {"location_stop", 0, nif_location_stop, 0}, + {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, + {"camera_capture_video", 1, nif_camera_capture_video, 0}, + {"camera_start_preview", 1, nif_camera_start_preview, 0}, + {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, + {"photos_pick", 2, nif_photos_pick, 0}, + {"files_pick", 1, nif_files_pick, 0}, + {"audio_start_recording", 1, nif_audio_start_recording, 0}, + {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, + {"audio_play", 2, nif_audio_play, 0}, + {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, + {"audio_set_volume", 1, nif_audio_set_volume, 0}, + {"motion_start", 2, nif_motion_start, 0}, + {"motion_stop", 0, nif_motion_stop, 0}, + {"scanner_scan", 1, nif_scanner_scan, 0}, + {"notify_schedule", 1, nif_notify_schedule, 0}, + {"notify_cancel", 1, nif_notify_cancel, 0}, + {"notify_register_push", 0, nif_notify_register_push, 0}, + {"take_launch_notification", 0, nif_take_launch_notification, 0}, + {"storage_dir", 1, nif_storage_dir, 0}, + {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, + {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, + {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, + {"alert_show", 3, nif_alert_show, 0}, + {"action_sheet_show", 2, nif_action_sheet_show, 0}, + {"toast_show", 2, nif_toast_show, 0}, + {"webview_eval_js", 1, nif_webview_eval_js, 0}, + {"webview_post_message", 1, nif_webview_post_message, 0}, {"webview_can_go_back", 0, nif_webview_can_go_back, 0}, - {"webview_go_back", 0, nif_webview_go_back, 0}, - {"register_component", 1, nif_register_component, 0}, - {"deregister_component", 1, nif_deregister_component, 0}, - {"background_keep_alive", 0, nif_background_keep_alive, 0}, - {"background_stop", 0, nif_background_stop, 0}, + {"webview_go_back", 0, nif_webview_go_back, 0}, + {"register_component", 1, nif_register_component, 0}, + {"deregister_component", 1, nif_deregister_component, 0}, + {"background_keep_alive", 0, nif_background_keep_alive, 0}, + {"background_stop", 0, nif_background_stop, 0}, // ── Mob.Device — lifecycle events + queries (Android stubs) ─────────────── - {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, - {"device_battery_state", 0, nif_device_battery_state, 0}, - {"device_thermal_state", 0, nif_device_thermal_state, 0}, - {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, - {"device_foreground", 0, nif_device_foreground, 0}, - {"device_os_version", 0, nif_device_os_version, 0}, - {"device_model", 0, nif_device_model, 0}, + {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, + {"device_battery_state", 0, nif_device_battery_state, 0}, + {"device_thermal_state", 0, nif_device_thermal_state, 0}, + {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, + {"device_foreground", 0, nif_device_foreground, 0}, + {"device_os_version", 0, nif_device_os_version, 0}, + {"device_model", 0, nif_device_model, 0}, }; -static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { - LOGI("nif_load: entered, Bridge.cls=%p", (void*)Bridge.cls); - if (!Bridge.cls) { LOGE("Bridge.cls not cached — was mob_ui_cache_class called?"); return -1; } +static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { + LOGI("nif_load: entered, Bridge.cls=%p", (void *)Bridge.cls); + if (!Bridge.cls) { + LOGE("Bridge.cls not cached — was mob_ui_cache_class called?"); + return -1; + } tap_mutex = enif_mutex_create("mob_tap_mutex"); - if (!tap_mutex) { LOGE("nif_load: failed to create tap mutex"); return -1; } + if (!tap_mutex) { + LOGE("nif_load: failed to create tap mutex"); + return -1; + } component_mutex = enif_mutex_create("mob_component_mutex"); - if (!component_mutex) { LOGE("nif_load: failed to create component mutex"); return -1; } + if (!component_mutex) { + LOGE("nif_load: failed to create component mutex"); + return -1; + } - int att; JNIEnv* jenv = get_jenv(&att); - Bridge.set_root = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, - "setRootJson", "(Ljava/lang/String;Ljava/lang/String;)V"); - if (!Bridge.set_root) { LOGE("nif_load: setRootJson(String,String) not found on MobBridge"); return -1; } + int att; + JNIEnv *jenv = get_jenv(&att); + Bridge.set_root = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setRootJson", + "(Ljava/lang/String;Ljava/lang/String;)V"); + if (!Bridge.set_root) { + LOGE("nif_load: setRootJson(String,String) not found on MobBridge"); + return -1; + } Bridge.move_to_back = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "moveToBack", "()V"); - if (!Bridge.move_to_back) { LOGE("nif_load: moveToBack() not found on MobBridge"); return -1; } + if (!Bridge.move_to_back) { + LOGE("nif_load: moveToBack() not found on MobBridge"); + return -1; + } Bridge.get_safe_area = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "getSafeArea", "()[F"); - if (!Bridge.get_safe_area) { LOGE("nif_load: getSafeArea() not found on MobBridge"); return -1; } + if (!Bridge.get_safe_area) { + LOGE("nif_load: getSafeArea() not found on MobBridge"); + return -1; + } // getColorScheme() is optional — apps that haven't been regenerated since // it was added still load fine; nif_color_scheme falls back to :light. @@ -2204,81 +2456,110 @@ static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { } Bridge.haptic = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "haptic", "(Ljava/lang/String;)V"); - if (!Bridge.haptic) { LOGE("nif_load: haptic(String) not found on MobBridge"); return -1; } + if (!Bridge.haptic) { + LOGE("nif_load: haptic(String) not found on MobBridge"); + return -1; + } - Bridge.clipboard_put = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardPut", "(Ljava/lang/String;)V"); - if (!Bridge.clipboard_put) { LOGE("nif_load: clipboardPut(String) not found on MobBridge"); return -1; } + Bridge.clipboard_put = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardPut", "(Ljava/lang/String;)V"); + if (!Bridge.clipboard_put) { + LOGE("nif_load: clipboardPut(String) not found on MobBridge"); + return -1; + } - Bridge.clipboard_get = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardGet", "()Ljava/lang/String;"); - if (!Bridge.clipboard_get) { LOGE("nif_load: clipboardGet() not found on MobBridge"); return -1; } + Bridge.clipboard_get = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardGet", "()Ljava/lang/String;"); + if (!Bridge.clipboard_get) { + LOGE("nif_load: clipboardGet() not found on MobBridge"); + return -1; + } - Bridge.share_text = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "shareText", "(Ljava/lang/String;)V"); - if (!Bridge.share_text) { LOGE("nif_load: shareText(String) not found on MobBridge"); return -1; } + Bridge.share_text = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "shareText", "(Ljava/lang/String;)V"); + if (!Bridge.share_text) { + LOGE("nif_load: shareText(String) not found on MobBridge"); + return -1; + } - Bridge.open_url = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "openUrl", "(Ljava/lang/String;)V"); - if (!Bridge.open_url) { LOGE("nif_load: openUrl(String) not found on MobBridge"); return -1; } + Bridge.open_url = + (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "openUrl", "(Ljava/lang/String;)V"); + if (!Bridge.open_url) { + LOGE("nif_load: openUrl(String) not found on MobBridge"); + return -1; + } - #define CACHE(name, sig) \ - Bridge.name = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, #name, sig); \ - if (!Bridge.name) { LOGE("nif_load: " #name " not found"); return -1; } +#define CACHE(name, sig) \ + Bridge.name = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, #name, sig); \ + if (!Bridge.name) { \ + LOGE("nif_load: " #name " not found"); \ + return -1; \ + } - CACHE(request_permission, "(JLjava/lang/String;)V") + CACHE(request_permission, "(JLjava/lang/String;)V") CACHE(biometric_authenticate, "(JLjava/lang/String;)V") - CACHE(location_get_once, "(JLjava/lang/String;)V") - CACHE(location_start, "(JLjava/lang/String;)V") - CACHE(location_stop, "()V") - CACHE(camera_capture_photo, "(JLjava/lang/String;)V") - CACHE(camera_capture_video, "(JLjava/lang/String;)V") - CACHE(camera_start_preview, "(JLjava/lang/String;)V") - CACHE(camera_stop_preview, "()V") - CACHE(photos_pick, "(JLjava/lang/String;)V") - CACHE(files_pick, "(JLjava/lang/String;)V") - CACHE(audio_start_recording, "(JLjava/lang/String;)V") - CACHE(audio_stop_recording, "()V") - CACHE(audio_play, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(audio_stop_playback, "()V") - CACHE(audio_set_volume, "(Ljava/lang/String;)V") - CACHE(storage_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(storage_save_to_media_store, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(storage_external_files_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(alert_show, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V") - CACHE(action_sheet_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(toast_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(webview_eval_js, "(Ljava/lang/String;)V") - CACHE(webview_post_message, "(Ljava/lang/String;)V") - CACHE(webview_can_go_back, "()Z") - CACHE(webview_go_back, "()V") - CACHE(motion_start, "(JLjava/lang/String;)V") - CACHE(motion_stop, "()V") - CACHE(scanner_scan, "(JLjava/lang/String;)V") - CACHE(notify_schedule, "(JLjava/lang/String;)V") - CACHE(notify_cancel, "(Ljava/lang/String;)V") - CACHE(notify_register_push, "(JLjava/lang/String;)V") - CACHE(background_keep_alive, "()V") - CACHE(background_stop, "()V") - #undef CACHE + CACHE(location_get_once, "(JLjava/lang/String;)V") + CACHE(location_start, "(JLjava/lang/String;)V") + CACHE(location_stop, "()V") + CACHE(camera_capture_photo, "(JLjava/lang/String;)V") + CACHE(camera_capture_video, "(JLjava/lang/String;)V") + CACHE(camera_start_preview, "(JLjava/lang/String;)V") + CACHE(camera_stop_preview, "()V") + CACHE(photos_pick, "(JLjava/lang/String;)V") + CACHE(files_pick, "(JLjava/lang/String;)V") + CACHE(audio_start_recording, "(JLjava/lang/String;)V") + CACHE(audio_stop_recording, "()V") + CACHE(audio_play, "(JLjava/lang/String;Ljava/lang/String;)V") + CACHE(audio_stop_playback, "()V") + CACHE(audio_set_volume, "(Ljava/lang/String;)V") + CACHE(storage_dir, "(Ljava/lang/String;)Ljava/lang/String;") + CACHE(storage_save_to_media_store, "(JLjava/lang/String;Ljava/lang/String;)V") + CACHE(storage_external_files_dir, "(Ljava/lang/String;)Ljava/lang/String;") + CACHE(alert_show, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V") + CACHE(action_sheet_show, "(Ljava/lang/String;Ljava/lang/String;)V") + CACHE(toast_show, "(Ljava/lang/String;Ljava/lang/String;)V") + CACHE(webview_eval_js, "(Ljava/lang/String;)V") + CACHE(webview_post_message, "(Ljava/lang/String;)V") + CACHE(webview_can_go_back, "()Z") + CACHE(webview_go_back, "()V") + CACHE(motion_start, "(JLjava/lang/String;)V") + CACHE(motion_stop, "()V") + CACHE(scanner_scan, "(JLjava/lang/String;)V") + CACHE(notify_schedule, "(JLjava/lang/String;)V") + CACHE(notify_cancel, "(Ljava/lang/String;)V") + CACHE(notify_register_push, "(JLjava/lang/String;)V") + CACHE(background_keep_alive, "()V") + CACHE(background_stop, "()V") +#undef CACHE g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); - if (!g_launch_notif_mutex) { LOGE("nif_load: failed to create launch notif mutex"); return -1; } - - // ── Test harness method IDs (optional — clear exception if not present) ──── - #define CACHE_OPT(field, name, sig) \ - Bridge.field = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, name, sig); \ - if (!Bridge.field) { (*jenv)->ExceptionClear(jenv); LOGI("nif_load: %s not found (optional)", name); } - - CACHE_OPT(ui_tree, "uiTree", "()Ljava/lang/String;") - CACHE_OPT(ui_view_tree, "uiViewTree", "()Ljava/lang/String;") - CACHE_OPT(screen_info, "screenInfo", "()[F") - CACHE_OPT(tap_xy, "tapXy", "(FF)Z") - CACHE_OPT(tap_by_label, "tapByLabel", "(Ljava/lang/String;)Z") - CACHE_OPT(type_text, "typeText", "(Ljava/lang/String;)Z") - CACHE_OPT(delete_backward,"deleteBackward","()Z") - CACHE_OPT(clear_text, "clearText", "()Z") - CACHE_OPT(long_press_xy, "longPressXy", "(FFJ)Z") - CACHE_OPT(swipe_xy, "swipeXy", "(FFFF)Z") - #undef CACHE_OPT - - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + if (!g_launch_notif_mutex) { + LOGE("nif_load: failed to create launch notif mutex"); + return -1; + } + +// ── Test harness method IDs (optional — clear exception if not present) ──── +#define CACHE_OPT(field, name, sig) \ + Bridge.field = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, name, sig); \ + if (!Bridge.field) { \ + (*jenv)->ExceptionClear(jenv); \ + LOGI("nif_load: %s not found (optional)", name); \ + } + + CACHE_OPT(ui_tree, "uiTree", "()Ljava/lang/String;") + CACHE_OPT(ui_view_tree, "uiViewTree", "()Ljava/lang/String;") + CACHE_OPT(screen_info, "screenInfo", "()[F") + CACHE_OPT(tap_xy, "tapXy", "(FF)Z") + CACHE_OPT(tap_by_label, "tapByLabel", "(Ljava/lang/String;)Z") + CACHE_OPT(type_text, "typeText", "(Ljava/lang/String;)Z") + CACHE_OPT(delete_backward, "deleteBackward", "()Z") + CACHE_OPT(clear_text, "clearText", "()Z") + CACHE_OPT(long_press_xy, "longPressXy", "(FFJ)Z") + CACHE_OPT(swipe_xy, "swipeXy", "(FFFF)Z") +#undef CACHE_OPT + + if (att) + (*g_jvm)->DetachCurrentThread(g_jvm); LOGI("Mob NIF loaded (Compose backend)"); return 0; diff --git a/ios/MobNode.m b/ios/MobNode.m index 3061b949..4aa33b4a 100644 --- a/ios/MobNode.m +++ b/ios/MobNode.m @@ -7,40 +7,40 @@ @implementation MobNode - (instancetype)init { if ((self = [super init])) { - _textSize = 14.0; - _padding = 0.0; - _paddingTop = -1.0; - _paddingRight = -1.0; + _textSize = 14.0; + _padding = 0.0; + _paddingTop = -1.0; + _paddingRight = -1.0; _paddingBottom = -1.0; - _paddingLeft = -1.0; - _fontWeight = @"regular"; - _textAlign = @"left"; - _italic = NO; - _lineHeight = 0.0; + _paddingLeft = -1.0; + _fontWeight = @"regular"; + _textAlign = @"left"; + _italic = NO; + _lineHeight = 0.0; _letterSpacing = 0.0; - _thickness = 1.0; - _fixedSize = 0.0; - _value = NAN; // NaN = indeterminate (progress) or not-yet-set (slider) - _minValue = 0.0; - _maxValue = 1.0; - _checked = NO; - _axis = @"vertical"; - _showIndicator = YES; - _rowAlign = @"center"; - _boxAlign = @"top_leading"; - _offsetX = 0.0; - _offsetY = 0.0; + _thickness = 1.0; + _fixedSize = 0.0; + _value = NAN; // NaN = indeterminate (progress) or not-yet-set (slider) + _minValue = 0.0; + _maxValue = 1.0; + _checked = NO; + _axis = @"vertical"; + _showIndicator = YES; + _rowAlign = @"center"; + _boxAlign = @"top_leading"; + _offsetX = 0.0; + _offsetY = 0.0; _keyboardTypeStr = @"default"; - _returnKeyStr = @"done"; - _contentModeStr = @"fit"; - _fixedWidth = 0.0; - _fixedHeight = 0.0; - _fillWidth = NO; - _cornerRadius = 0.0; + _returnKeyStr = @"done"; + _contentModeStr = @"fit"; + _fixedWidth = 0.0; + _fixedHeight = 0.0; + _fillWidth = NO; + _cornerRadius = 0.0; _videoAutoplay = NO; - _videoLoop = NO; + _videoLoop = NO; _videoControls = YES; - _children = [NSMutableArray array]; + _children = [NSMutableArray array]; } return self; } diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 53e63d83..2cb432a8 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -417,20 +417,20 @@ struct MobNodeView: View { if let src = node.src { MobVideoPlayer(src: src, autoplay: node.videoAutoplay, loop: node.videoLoop, controls: node.videoControls) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) } case .cameraPreview: MobCameraPreviewView(facing: node.cameraFacing) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) case .webView: MobWebView(node: node) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) @@ -573,16 +573,14 @@ private struct MobCanvasView: View { let r = cgNum(op["r"]) let rect = CGRect(x: cgNum(op["x"]) - r, y: cgNum(op["y"]) - r, width: r * 2, height: r * 2) let path = Path(ellipseIn: rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "ellipse": let rx = cgNum(op["rx"]) let ry = cgNum(op["ry"]) let rect = CGRect(x: cgNum(op["x"]) - rx, y: cgNum(op["y"]) - ry, width: rx * 2, height: ry * 2) let path = Path(ellipseIn: rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "arc": // Mob.Canvas arc convention: degrees, 0° to the right, sweeping clockwise. @@ -607,8 +605,7 @@ private struct MobCanvasView: View { let path: Path = radius > 0 ? Path(roundedRect: rect, cornerRadius: radius) : Path(rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "path": guard let pts = op["points"] as? [[Double]], !pts.isEmpty else { return } @@ -620,8 +617,7 @@ private struct MobCanvasView: View { } if closed || isFill { p.closeSubpath() } } - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "text": let str = (op["text"] as? String) ?? "" @@ -1007,8 +1003,7 @@ private struct MobTextField: View { node.onChangeStr?(newValue) } .onChange(of: isFocused) { _, focused in - if focused { node.onFocus?() } - else { node.onBlur?() } + if focused { node.onFocus?() } else { node.onBlur?() } } // Sync from parent when the `value:` prop changes externally — // but only if the user isn't actively typing (which would yank @@ -1092,7 +1087,7 @@ private struct MobImage: View { var body: some View { Group { if let src = node.src { - if (src.hasPrefix("http://") || src.hasPrefix("https://")), + if src.hasPrefix("http://") || src.hasPrefix("https://"), let url = URL(string: src) { AsyncImage(url: url) { phase in switch phase { @@ -1114,7 +1109,7 @@ private struct MobImage: View { } } .frame( - width: node.fixedWidth > 0 ? node.fixedWidth : nil, + width: node.fixedWidth > 0 ? node.fixedWidth : nil, height: node.fixedHeight > 0 ? node.fixedHeight : nil ) .clipShape(RoundedRectangle(cornerRadius: node.cornerRadius)) @@ -1126,7 +1121,7 @@ private struct MobImage: View { public struct MobRootView: View { @ObservedObject var model = MobViewModel.shared @Environment(\.colorScheme) private var colorScheme - @State private var currentRoot: MobNode? = nil + @State private var currentRoot: MobNode? @State private var currentTransition: String = "none" // Local mirror of model.navVersion so the .id() change happens INSIDE // the withAnimation block (the model's @Published value changes via @@ -1220,13 +1215,13 @@ public struct MobRootView: View { switch t { case "push": return .asymmetric( - insertion: .move(edge: .trailing), - removal: .move(edge: .leading) + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) ) case "pop": return .asymmetric( - insertion: .move(edge: .leading), - removal: .move(edge: .trailing) + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) ) case "reset": return .opacity @@ -1285,7 +1280,7 @@ struct MobScrollObserver: ViewModifier { @State private var lastTs: TimeInterval = 0 @State private var hasBegun: Bool = false @State private var pastThreshold: Bool = false - @State private var endTask: Task? = nil + @State private var endTask: Task? private static let endDebounceMs: Int = 150 diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 09f26361..28168930 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -7,7 +7,7 @@ import Combine @objc public class MobViewModel: NSObject, ObservableObject { @objc public static let shared = MobViewModel() - @Published public var root: MobNode? = nil + @Published public var root: MobNode? /// Increments on every setRoot call; views use onChange(of: rootVersion) to /// trigger withAnimation rather than watching root directly (root identity /// may change even for same-screen re-renders). @@ -23,7 +23,7 @@ import Combine /// Current startup phase message shown while BEAM is initialising. @Published public var startupPhase: String = "Starting…" /// Non-nil when a fatal startup error has occurred; the error screen stalls here. - @Published public var startupError: String? = nil + @Published public var startupError: String? @objc public func setRoot(_ node: MobNode?, transition: String) { DispatchQueue.main.async { diff --git a/ios/driver_tab_ios.c b/ios/driver_tab_ios.c index fd100683..0a2f5e32 100644 --- a/ios/driver_tab_ios.c +++ b/ios/driver_tab_ios.c @@ -13,26 +13,29 @@ #include -typedef struct { void* de; int flags; } ErtsStaticDriver; +typedef struct { + void *de; + int flags; +} ErtsStaticDriver; #define THE_NON_VALUE ((unsigned long)0) typedef struct { - void* (*nif_init)(void); - int is_builtin; + void *(*nif_init)(void); + int is_builtin; unsigned long nif_mod; - void* entry; + void *entry; } ErtsStaticNif; -typedef struct { void* de; int flags; } ErlDrvEntryStub; +typedef struct { + void *de; + int flags; +} ErlDrvEntryStub; extern ErlDrvEntryStub inet_driver_entry; extern ErlDrvEntryStub ram_file_driver_entry; -ErtsStaticDriver driver_tab[] = { - {&inet_driver_entry, 0}, - {&ram_file_driver_entry, 0}, - {NULL, 0} -}; +ErtsStaticDriver driver_tab[] = {{&inet_driver_entry, 0}, {&ram_file_driver_entry, 0}, {NULL, 0}}; -void erts_init_static_drivers(void) {} +void erts_init_static_drivers(void) { +} void *prim_tty_nif_init(void); void *erl_tracer_nif_init(void); @@ -62,20 +65,18 @@ void *mob_nif_nif_init(void); void *sqlite3_nif_nif_init(void); #endif -ErtsStaticNif erts_static_nif_tab[] = { - {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, - {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, - {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, - {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, +ErtsStaticNif erts_static_nif_tab[] = {{prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, + {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, + {zlib_nif_init, 0, THE_NON_VALUE, NULL}, + {zstd_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, + {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, + {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, + {crypto_nif_init, 1, THE_NON_VALUE, NULL}, + {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, #ifdef MOB_STATIC_SQLITE_NIF - {sqlite3_nif_nif_init, 0, THE_NON_VALUE, NULL}, + {sqlite3_nif_nif_init, 0, THE_NON_VALUE, NULL}, #endif - {NULL, 0, THE_NON_VALUE, NULL} -}; + {NULL, 0, THE_NON_VALUE, NULL}}; diff --git a/ios/mob_beam.m b/ios/mob_beam.m index 6c55bb2e..36014ec8 100644 --- a/ios/mob_beam.m +++ b/ios/mob_beam.m @@ -3,17 +3,17 @@ // mob_set_startup_phase/error are implemented in mob_nif.m (which imports the // Swift-generated header) so this file stays free of app-specific includes. +#include "mob_beam.h" #import -#include -#include +#include #include -#include -#include #include #include -#include +#include +#include +#include #include -#include "mob_beam.h" +#include // EPMD compiled into the binary (epmd.c / epmd_srv.c / epmd_cli.c compiled // with -Dmain=epmd_ios_main). Only present in device builds; the simulator @@ -26,9 +26,9 @@ // still works, but the app is networkless from a distribution POV. #if defined(MOB_BUNDLE_OTP) && !defined(MOB_RELEASE) extern int epmd_ios_main(int argc, char **argv); -static void* epmd_thread(void *arg) { +static void *epmd_thread(void *arg) { char *args[] = {"epmd", NULL}; - epmd_ios_main(1, args); // runs the EPMD event loop (does not return) + epmd_ios_main(1, args); // runs the EPMD event loop (does not return) return NULL; } #endif @@ -47,7 +47,7 @@ #define OTP_ROOT_LEGACY "/tmp/otp-ios-sim" #endif #ifndef ERTS_VSN -#define ERTS_VSN "erts-17.0" +#define ERTS_VSN "erts-17.0" #endif #ifndef OTP_RELEASE #define OTP_RELEASE "29" @@ -73,7 +73,8 @@ // route to os_log, so the failure is invisible. static const char *resolve_sim_otp_root(const char *app_module) { const char *env = getenv("MOB_SIM_RUNTIME_DIR"); - if (env && env[0]) return env; + if (env && env[0]) + return env; // iOS sim apps inherit HOME pointing to the per-app sandbox container // (…/CoreSimulator/Devices//data/Containers/Data/Application/), @@ -82,11 +83,11 @@ // ~/.mob/runtime/ios-sim. Fall back to HOME so this still works when the // binary runs outside simctl (e.g. raw test harness on the Mac). const char *home = getenv("SIMULATOR_HOST_HOME"); - if (!home || !home[0]) home = getenv("HOME"); + if (!home || !home[0]) + home = getenv("HOME"); if (home && app_module && app_module[0]) { static char new_default[1024]; - snprintf(new_default, sizeof(new_default), - "%s/.mob/runtime/ios-sim", home); + snprintf(new_default, sizeof(new_default), "%s/.mob/runtime/ios-sim", home); char check[1280]; snprintf(check, sizeof(check), "%s/%s", new_default, app_module); @@ -110,20 +111,25 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i char path[1024]; snprintf(path, sizeof(path), "%s/%s", docs_dir, name); FILE *f = fopen(path, "w"); - if (f) { fprintf(f, "%s\n", info); fclose(f); } + if (f) { + fprintf(f, "%s\n", info); + fclose(f); + } } // Find the device's own USB link-local (169.254.x.x) IP by walking ifaddrs. // On simulator there is no such interface; returns NULL so callers fall back to 127.0.0.1. static const char *find_link_local_ip(char *buf, size_t len) { struct ifaddrs *ifa_list; - if (getifaddrs(&ifa_list) != 0) return NULL; + if (getifaddrs(&ifa_list) != 0) + return NULL; const char *found = NULL; for (struct ifaddrs *ifa = ifa_list; ifa && !found; ifa = ifa->ifa_next) { - if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) + continue; struct sockaddr_in *sa = (struct sockaddr_in *)ifa->ifa_addr; uint32_t addr = ntohl(sa->sin_addr.s_addr); - if ((addr >> 16) == 0xA9FE) { // 169.254.0.0/16 + if ((addr >> 16) == 0xA9FE) { // 169.254.0.0/16 inet_ntop(AF_INET, &sa->sin_addr, buf, (socklen_t)len); found = buf; } @@ -136,18 +142,20 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i // when no USB link-local interface is present. Returns NULL if none found. static const char *find_lan_ip(char *buf, size_t len) { struct ifaddrs *ifa_list; - if (getifaddrs(&ifa_list) != 0) return NULL; + if (getifaddrs(&ifa_list) != 0) + return NULL; const char *found = NULL; for (struct ifaddrs *ifa = ifa_list; ifa && !found; ifa = ifa->ifa_next) { - if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) + continue; struct sockaddr_in *sa = (struct sockaddr_in *)ifa->ifa_addr; uint32_t addr = ntohl(sa->sin_addr.s_addr); - uint32_t top8 = addr >> 24; + uint32_t top8 = addr >> 24; uint32_t top16 = addr >> 16; - if (top8 == 10 || // 10.0.0.0/8 - (top16 >= 0xAC10 && top16 <= 0xAC1F) || // 172.16.0.0/12 - top16 == 0xC0A8 || // 192.168.0.0/16 - (top16 >= 0x6440 && top16 <= 0x647F)) { // 100.64.0.0/10 (Tailscale) + if (top8 == 10 || // 10.0.0.0/8 + (top16 >= 0xAC10 && top16 <= 0xAC1F) || // 172.16.0.0/12 + top16 == 0xC0A8 || // 192.168.0.0/16 + (top16 >= 0x6440 && top16 <= 0x647F)) { // 100.64.0.0/10 (Tailscale) inet_ntop(AF_INET, &sa->sin_addr, buf, (socklen_t)len); found = buf; } @@ -156,7 +164,7 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i return found; } -void mob_start_beam(const char* app_module) { +void mob_start_beam(const char *app_module) { mob_set_startup_phase("Setting up BEAM environment…"); // Resolve Documents dir early for diagnostics. @@ -171,8 +179,8 @@ void mob_start_beam(const char* app_module) { // reads MOB_SIM_RUNTIME_DIR (set by mix mob.deploy via simctl) with a /tmp // fallback for legacy projects. #ifdef MOB_BUNDLE_OTP - NSString *bundle_otp = [[[NSBundle mainBundle] bundlePath] - stringByAppendingPathComponent:@"otp"]; + NSString *bundle_otp = + [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"otp"]; const char *otp_root = [bundle_otp UTF8String]; const char *erts_vsn = ERTS_VSN; const char *otp_release = OTP_RELEASE; @@ -186,19 +194,19 @@ void mob_start_beam(const char* app_module) { // Compose dynamic paths that depend on otp_root. static char bindir[512], elixir_dir[512], logger_dir[512], boot_path[512]; - snprintf(bindir, sizeof(bindir), "%s/%s/bin", otp_root, erts_vsn); - snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); - snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); - snprintf(boot_path, sizeof(boot_path), "%s/releases/%s/start_clean", otp_root, otp_release); + snprintf(bindir, sizeof(bindir), "%s/%s/bin", otp_root, erts_vsn); + snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); + snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); + snprintf(boot_path, sizeof(boot_path), "%s/releases/%s/start_clean", otp_root, otp_release); mob_write_diag(docs_dir, "mob_diag_c_paths.txt", bindir); NSLog(@"[MobBeam] otp_root=%s erts=%s release=%s", otp_root, erts_vsn, otp_release); - setenv("BINDIR", bindir, 1); - setenv("ROOTDIR", otp_root, 1); + setenv("BINDIR", bindir, 1); + setenv("ROOTDIR", otp_root, 1); setenv("PROGNAME", "erl", 1); - setenv("EMU", "beam", 1); - setenv("HOME", "/tmp", 1); + setenv("EMU", "beam", 1); + setenv("HOME", "/tmp", 1); // Set MOB_DATA_DIR to the app's Documents directory — persistent storage // accessible to the app and backed up by iCloud. Used by the generated Repo // module to determine where to place the SQLite database file. @@ -240,7 +248,7 @@ void mob_start_beam(const char* app_module) { // Physical device: WiFi/LAN → USB link-local → loopback fallback. static char lan_ip_buf[64], link_local_buf[64]; const char *lan_ip = find_lan_ip(lan_ip_buf, sizeof(lan_ip_buf)); - const char *ll_ip = lan_ip ? NULL : find_link_local_ip(link_local_buf, sizeof(link_local_buf)); + const char *ll_ip = lan_ip ? NULL : find_link_local_ip(link_local_buf, sizeof(link_local_buf)); const char *host_ip = lan_ip ? lan_ip : (ll_ip ? ll_ip : "127.0.0.1"); static char eval_expr[280], node_name[128], beams_dir[512]; snprintf(eval_expr, sizeof(eval_expr), "%s:start().", app_module); @@ -281,7 +289,7 @@ void mob_start_beam(const char* app_module) { // If that directory exists, prefer it over the in-bundle copy. static char docs_beams[512]; snprintf(docs_beams, sizeof(docs_beams), "%s/otp/%s", docs_dir, app_module); - if ([[NSFileManager defaultManager] fileExistsAtPath:@(docs_beams)]) { + if ([[NSFileManager defaultManager] fileExistsAtPath:@(docs_beams)]) { strlcpy(beams_dir, docs_beams, sizeof(beams_dir)); } mob_write_diag(docs_dir, "mob_diag_beams_dir.txt", beams_dir); @@ -302,15 +310,13 @@ void mob_start_beam(const char* app_module) { // Compile-time default BEAM tuning flags. // Overridden at runtime if beams_dir/mob_beam_flags exists // (written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`). - static const char* s_default_flags[] = { - "-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", "-A", "1", "-sbwt", "none", - NULL - }; + static const char *s_default_flags[] = {"-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", + "-A", "1", "-sbwt", "none", NULL}; // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. - static char s_flags_buf[512] = {0}; - static const char* s_runtime_flags[64] = {NULL}; - static int s_runtime_flag_count = 0; + static char s_flags_buf[512] = {0}; + static const char *s_runtime_flags[64] = {NULL}; + static int s_runtime_flag_count = 0; { char flags_path[640]; snprintf(flags_path, sizeof(flags_path), "%s/mob_beam_flags", beams_dir); @@ -322,59 +328,79 @@ void mob_start_beam(const char* app_module) { s_runtime_flag_count = 0; char *p = s_flags_buf; while (*p && s_runtime_flag_count < 63) { - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; - if (!*p) break; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + if (!*p) + break; s_runtime_flags[s_runtime_flag_count++] = p; - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; - if (*p) *p++ = '\0'; + while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') + p++; + if (*p) + *p++ = '\0'; } s_runtime_flags[s_runtime_flag_count] = NULL; NSLog(@"[MobBeam] loaded %d runtime flags from %s", s_runtime_flag_count, flags_path); } } - const char** selected_flags = (s_runtime_flag_count > 0) - ? s_runtime_flags - : s_default_flags; + const char **selected_flags = (s_runtime_flag_count > 0) ? s_runtime_flags : s_default_flags; - static const char* args[128]; + static const char *args[128]; int ac = 0; args[ac++] = "beam"; - for (int i = 0; selected_flags[i]; i++) args[ac++] = selected_flags[i]; + for (int i = 0; selected_flags[i]; i++) + args[ac++] = selected_flags[i]; // Cap the BEAM's memory super carrier to 10MB on physical iOS devices. // The default 1GB virtual reservation is rejected by iOS on real hardware // (not on simulator where the Mac's VM handles it). Without this the BEAM // crashes immediately during startup on any physical iOS device. #ifdef MOB_BUNDLE_OTP - args[ac++] = "-MIscs"; args[ac++] = "10"; + args[ac++] = "-MIscs"; + args[ac++] = "10"; #endif args[ac++] = "--"; - args[ac++] = "-root"; args[ac++] = otp_root; - args[ac++] = "-bindir"; args[ac++] = bindir; - args[ac++] = "-progname"; args[ac++] = "erl"; + args[ac++] = "-root"; + args[ac++] = otp_root; + args[ac++] = "-bindir"; + args[ac++] = bindir; + args[ac++] = "-progname"; + args[ac++] = "erl"; args[ac++] = "--"; #ifndef MOB_RELEASE // Distribution flags. Omitted for App Store builds — see MOB_RELEASE // notes at the top of this file. - args[ac++] = "-name"; args[ac++] = node_name; - args[ac++] = "-setcookie"; args[ac++] = "mob_secret"; - args[ac++] = "-kernel"; args[ac++] = "inet_dist_listen_min"; args[ac++] = dist_port_min; - args[ac++] = "-kernel"; args[ac++] = "inet_dist_listen_max"; args[ac++] = dist_port_max; + args[ac++] = "-name"; + args[ac++] = node_name; + args[ac++] = "-setcookie"; + args[ac++] = "mob_secret"; + args[ac++] = "-kernel"; + args[ac++] = "inet_dist_listen_min"; + args[ac++] = dist_port_min; + args[ac++] = "-kernel"; + args[ac++] = "inet_dist_listen_max"; + args[ac++] = dist_port_max; #else // Mark MOB_RELEASE in env so Mob.Dist.ensure_started/1 short-circuits // before trying Node.start (which would fail without -name anyway, but // the env var lets app code probe for release mode without parsing // erl args). setenv("MOB_RELEASE", "1", 1); - (void)dist_port_min; (void)dist_port_max; (void)node_name; + (void)dist_port_min; + (void)dist_port_max; + (void)node_name; #endif args[ac++] = "-noshell"; args[ac++] = "-noinput"; - args[ac++] = "-boot"; args[ac++] = boot_path; - args[ac++] = "-pa"; args[ac++] = elixir_dir; - args[ac++] = "-pa"; args[ac++] = logger_dir; - args[ac++] = "-pa"; args[ac++] = beams_dir; - args[ac++] = "-eval"; args[ac++] = eval_expr; + args[ac++] = "-boot"; + args[ac++] = boot_path; + args[ac++] = "-pa"; + args[ac++] = elixir_dir; + args[ac++] = "-pa"; + args[ac++] = logger_dir; + args[ac++] = "-pa"; + args[ac++] = beams_dir; + args[ac++] = "-eval"; + args[ac++] = eval_expr; args[ac] = NULL; NSLog(@"[MobBeam] mob_start_beam: starting BEAM module=%s argc=%d", app_module, ac); mob_set_startup_phase("Starting BEAM…"); @@ -398,11 +424,11 @@ void mob_start_beam(const char* app_module) { pthread_t epmd_t; pthread_create(&epmd_t, NULL, epmd_thread, NULL); pthread_detach(epmd_t); - usleep(300000); // 300ms — give EPMD time to bind port 4369 + usleep(300000); // 300ms — give EPMD time to bind port 4369 #endif - void erl_start(int, char**); - erl_start(ac, (char**)args); + void erl_start(int, char **); + erl_start(ac, (char **)args); mob_write_diag(docs_dir, "mob_diag_e_erl_exited.txt", "erl_start returned"); mob_set_startup_error("BEAM exited unexpectedly — check Documents/mob_erl_crash.dump"); NSLog(@"[MobBeam] mob_start_beam: erl_start returned (unexpected)"); diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 40a9f38d..6462872e 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -10,32 +10,32 @@ #import #import +#include #include #include #include -#include // dlopen/dlsym are marked unavailable in iOS SDK headers but exist at runtime // in the iOS Simulator (macOS). Declare prototypes directly to bypass the header // restriction. On a real device these will be NULL (weak symbols). #ifndef RTLD_DEFAULT #define RTLD_DEFAULT ((void *)-2L) -#define RTLD_LAZY 1 +#define RTLD_LAZY 1 #endif -extern void *dlopen(const char *path, int mode) __attribute__((weak)); +extern void *dlopen(const char *path, int mode) __attribute__((weak)); extern void *dlsym(void *handle, const char *symbol) __attribute__((weak)); extern char *dlerror(void) __attribute__((weak)); -#import +#import "MobApp-Swift.h" +#import "MobNode.h" +#include "erl_nif.h" +#import #import #import +#import #import #import -#import -#import #import +#import #include -#include "erl_nif.h" -#import "MobNode.h" -#import "MobApp-Swift.h" #define LOGI(...) NSLog(@"[MobNIF] " __VA_ARGS__) #define LOGE(...) NSLog(@"[MobNIF][ERROR] " __VA_ARGS__) @@ -44,12 +44,12 @@ // Implemented here rather than in mob_beam.m because this file is compiled with // -I $BUILD_DIR so it can import the Swift-generated MobApp-Swift.h header. -void mob_set_startup_phase(const char* phase) { +void mob_set_startup_phase(const char *phase) { NSLog(@"[MobBeam] startup: %s", phase); [MobViewModel.shared setStartupPhase:[NSString stringWithUTF8String:phase]]; } -void mob_set_startup_error(const char* error) { +void mob_set_startup_error(const char *error) { NSLog(@"[MobBeam] ERROR: %s", error); [MobViewModel.shared setStartupError:[NSString stringWithUTF8String:error]]; } @@ -60,46 +60,45 @@ void mob_set_startup_error(const char* error) { #define MAX_TAP_HANDLES 256 typedef struct { - ErlNifPid pid; - ErlNifEnv* tag_env; // persistent env owning tag; NULL when slot is free + ErlNifPid pid; + ErlNifEnv *tag_env; // persistent env owning tag; NULL when slot is free ERL_NIF_TERM tag; // ── Batch 5 throttle state — populated by mob_set_throttle_config ── - int throttle_ms; // 0 = no throttle (raw firing) - int debounce_ms; // 0 = no debounce - double delta_threshold; - int leading; // 1 = emit first event of burst - int trailing; // 1 = emit final event after debounce - uint64_t last_emit_ns; // mach_absolute_time of last successful emit - double last_x; // last emitted x (for delta check) - double last_y; // last emitted y - uint64_t seq; // monotonic counter per handle + int throttle_ms; // 0 = no throttle (raw firing) + int debounce_ms; // 0 = no debounce + double delta_threshold; + int leading; // 1 = emit first event of burst + int trailing; // 1 = emit final event after debounce + uint64_t last_emit_ns; // mach_absolute_time of last successful emit + double last_x; // last emitted x (for delta check) + double last_y; // last emitted y + uint64_t seq; // monotonic counter per handle } TapHandle; -static TapHandle tap_handles[MAX_TAP_HANDLES]; -static int tap_handle_next = 0; -static ErlNifMutex* tap_mutex = NULL; +static TapHandle tap_handles[MAX_TAP_HANDLES]; +static int tap_handle_next = 0; +static ErlNifMutex *tap_mutex = NULL; // Convert mach absolute time to nanoseconds (initialised once). static mach_timebase_info_data_t g_timebase = {0, 0}; static uint64_t mob_now_ns(void) { - if (g_timebase.denom == 0) mach_timebase_info(&g_timebase); + if (g_timebase.denom == 0) + mach_timebase_info(&g_timebase); return mach_absolute_time() * g_timebase.numer / g_timebase.denom; } // Set throttle config for a handle. Called from the prop deserialiser when // it sees a *_config sibling prop. Idempotent — safe to call multiple times. -static void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, - int leading, int trailing) { +static void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, + double delta_threshold, int leading, int trailing) { enif_mutex_lock(tap_mutex); if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { - tap_handles[handle].throttle_ms = throttle_ms; - tap_handles[handle].debounce_ms = debounce_ms; + tap_handles[handle].throttle_ms = throttle_ms; + tap_handles[handle].debounce_ms = debounce_ms; tap_handles[handle].delta_threshold = delta_threshold; - tap_handles[handle].leading = leading; - tap_handles[handle].trailing = trailing; + tap_handles[handle].leading = leading; + tap_handles[handle].trailing = trailing; } enif_mutex_unlock(tap_mutex); } @@ -110,14 +109,14 @@ static void mob_set_throttle_config(int handle, // Defaults (when throttle/delta unset on a handle): use reasonable per-event // fallbacks so widgets that opt in without explicit config still get sane // gating. -static int mob_throttle_check(int handle, double x, double y, - int default_throttle_ms, double default_delta) { +static int mob_throttle_check(int handle, double x, double y, int default_throttle_ms, + double default_delta) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return 0; } - TapHandle* h = &tap_handles[handle]; + TapHandle *h = &tap_handles[handle]; int throttle_ms = h->throttle_ms ? h->throttle_ms : default_throttle_ms; double delta_threshold = h->delta_threshold > 0 ? h->delta_threshold : default_delta; @@ -151,18 +150,18 @@ static int mob_throttle_check(int handle, double x, double y, } // Read current seq + ts for a handle (for envelope construction). -static void mob_handle_meta(int handle, uint64_t* seq_out, uint64_t* ts_out) { +static void mob_handle_meta(int handle, uint64_t *seq_out, uint64_t *ts_out) { enif_mutex_lock(tap_mutex); if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { *seq_out = tap_handles[handle].seq; - *ts_out = mob_now_ns() / 1000000ULL; // ms since boot + *ts_out = mob_now_ns() / 1000000ULL; // ms since boot } else { *seq_out = 0; - *ts_out = 0; + *ts_out = 0; } enif_mutex_unlock(tap_mutex); } -static char g_transition[16] = "none"; +static char g_transition[16] = "none"; // Called from node onTap blocks — routes tap to BEAM via enif_send. static void mob_send_tap(int handle) { @@ -171,14 +170,13 @@ static void mob_send_tap(int handle) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; + ErlNifPid pid = tap_handles[handle].pid; + ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, "tap"), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, "tap"), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -186,43 +184,50 @@ static void mob_send_tap(int handle) { // ── Focus / blur / submit senders ──────────────────────────────────────────── // Called from MobTextField SwiftUI view when focus state changes or return key tapped. -static void mob_send_event(int handle, const char* atom) { +static void mob_send_event(int handle, const char *atom) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, atom), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, atom), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_focus(int handle) { mob_send_event(handle, "focus"); } -static void mob_send_blur(int handle) { mob_send_event(handle, "blur"); } -static void mob_send_submit(int handle) { mob_send_event(handle, "submit"); } -static void mob_send_select(int handle) { mob_send_event(handle, "select"); } +static void mob_send_focus(int handle) { + mob_send_event(handle, "focus"); +} +static void mob_send_blur(int handle) { + mob_send_event(handle, "blur"); +} +static void mob_send_submit(int handle) { + mob_send_event(handle, "submit"); +} +static void mob_send_select(int handle) { + mob_send_event(handle, "select"); +} // IME composition. Sends {compose, tag, %{text: ..., phase: ...}} where // phase is one of began/updating/committed/cancelled. Called from the // text-input layer when marked-text state changes. -static void mob_send_compose(int handle, const char* text, const char* phase) { +static void mob_send_compose(int handle, const char *text, const char *phase) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[2] = { enif_make_atom(msg_env, "text"), enif_make_atom(msg_env, "phase"), @@ -233,10 +238,8 @@ static void mob_send_compose(int handle, const char* text, const char* phase) { }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 2, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "compose"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "compose"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -245,29 +248,40 @@ static void mob_send_compose(int handle, const char* text, const char* phase) { // Each fires {atom, tag} just like tap. SwiftUI converts gesture recognizers // into onLongPress/onDoubleTap/onSwipe* callbacks on the MobNode. -static void mob_send_long_press(int handle) { mob_send_event(handle, "long_press"); } -static void mob_send_double_tap(int handle) { mob_send_event(handle, "double_tap"); } -static void mob_send_swipe_left(int handle) { mob_send_event(handle, "swipe_left"); } -static void mob_send_swipe_right(int handle) { mob_send_event(handle, "swipe_right"); } -static void mob_send_swipe_up(int handle) { mob_send_event(handle, "swipe_up"); } -static void mob_send_swipe_down(int handle) { mob_send_event(handle, "swipe_down"); } +static void mob_send_long_press(int handle) { + mob_send_event(handle, "long_press"); +} +static void mob_send_double_tap(int handle) { + mob_send_event(handle, "double_tap"); +} +static void mob_send_swipe_left(int handle) { + mob_send_event(handle, "swipe_left"); +} +static void mob_send_swipe_right(int handle) { + mob_send_event(handle, "swipe_right"); +} +static void mob_send_swipe_up(int handle) { + mob_send_event(handle, "swipe_up"); +} +static void mob_send_swipe_down(int handle) { + mob_send_event(handle, "swipe_down"); +} // Generic on_swipe with direction: emits {swipe, tag, direction} where direction is an atom. -static void mob_send_swipe_with_direction(int handle, const char* direction) { +static void mob_send_swipe_with_direction(int handle, const char *direction) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "swipe"), - enif_make_copy(msg_env, tag), - enif_make_atom(msg_env, direction)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "swipe"), enif_make_copy(msg_env, tag), + enif_make_atom(msg_env, direction)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -286,33 +300,20 @@ static void mob_send_swipe_with_direction(int handle, const char* direction) { // :pointer_move 33 ms / 4 px // Build a payload map: %{x, y, dx, dy, velocity_x, velocity_y, phase, ts, seq} -static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv* env, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase, - uint64_t ts, uint64_t seq) { +static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv *env, double x, double y, double dx, + double dy, double vx, double vy, const char *phase, + uint64_t ts, uint64_t seq) { ERL_NIF_TERM keys[9] = { - enif_make_atom(env, "x"), - enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), - enif_make_atom(env, "dy"), - enif_make_atom(env, "velocity_x"), - enif_make_atom(env, "velocity_y"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), + enif_make_atom(env, "x"), enif_make_atom(env, "y"), + enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), + enif_make_atom(env, "velocity_x"), enif_make_atom(env, "velocity_y"), + enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[9] = { - enif_make_double(env, x), - enif_make_double(env, y), - enif_make_double(env, dx), - enif_make_double(env, dy), - enif_make_double(env, vx), - enif_make_double(env, vy), - enif_make_atom(env, phase), - enif_make_uint64(env, ts), - enif_make_uint64(env, seq), + enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), + enif_make_double(env, dy), enif_make_double(env, vx), enif_make_double(env, vy), + enif_make_atom(env, phase), enif_make_uint64(env, ts), enif_make_uint64(env, seq), }; ERL_NIF_TERM map; enif_make_map_from_arrays(env, keys, vals, 9, &map); @@ -321,193 +322,194 @@ static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv* env, // Send a throttled high-frequency event. Phase is one of: // "began" | "dragging" | "decelerating" | "ended" -static void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase) { +static void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, + double vy, const char *phase) { // Force-emit for began/ended phases regardless of throttle (semantic // boundaries are too important to drop). - int is_phase_boundary = (strcmp(phase, "began") == 0) || - (strcmp(phase, "ended") == 0); + int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 33, 1.0)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 33, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM payload = mob_build_scroll_payload(msg_env, x, y, dx, dy, vx, vy, phase, ts, seq); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "scroll"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "scroll"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase) { - int is_phase_boundary = (strcmp(phase, "began") == 0) || - (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 16, 1.0)) return; +static void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase) { + int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); + if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); // Drag payload: %{x, y, dx, dy, phase, ts, seq} ERL_NIF_TERM keys[7] = { - enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), - enif_make_atom(msg_env, "dx"), enif_make_atom(msg_env, "dy"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), + enif_make_atom(msg_env, "dx"), enif_make_atom(msg_env, "dy"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[7] = { - enif_make_double(msg_env, x), enif_make_double(msg_env, y), - enif_make_double(msg_env, dx), enif_make_double(msg_env, dy), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_double(msg_env, x), enif_make_double(msg_env, y), + enif_make_double(msg_env, dx), enif_make_double(msg_env, dy), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 7, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "drag"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "drag"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_pinch(int handle, double scale, double velocity, const char* phase) { +static void mob_send_pinch(int handle, double scale, double velocity, const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, scale, 0, 16, 0.01)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, scale, 0, 16, 0.01)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { enif_make_atom(msg_env, "scale"), enif_make_atom(msg_env, "velocity"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[5] = { enif_make_double(msg_env, scale), enif_make_double(msg_env, velocity), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "pinch"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "pinch"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_rotate(int handle, double degrees, double velocity, const char* phase) { +static void mob_send_rotate(int handle, double degrees, double velocity, const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, degrees, 0, 16, 1.0)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, degrees, 0, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { enif_make_atom(msg_env, "degrees"), enif_make_atom(msg_env, "velocity"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[5] = { enif_make_double(msg_env, degrees), enif_make_double(msg_env, velocity), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "rotate"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "rotate"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } static void mob_send_pointer_move(int handle, double x, double y) { - if (!mob_throttle_check(handle, x, y, 33, 4.0)) return; + if (!mob_throttle_check(handle, x, y, 33, 4.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[4] = { - enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "x"), + enif_make_atom(msg_env, "y"), + enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[4] = { - enif_make_double(msg_env, x), enif_make_double(msg_env, y), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_double(msg_env, x), + enif_make_double(msg_env, y), + enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 4, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "pointer_move"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "pointer_move"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } // ── Batch 5 Tier 2 senders — semantic single-fire scroll events ───────────── -static void mob_send_scroll_began(int handle) { mob_send_event(handle, "scroll_began"); } -static void mob_send_scroll_ended(int handle) { mob_send_event(handle, "scroll_ended"); } -static void mob_send_scroll_settled(int handle) { mob_send_event(handle, "scroll_settled"); } -static void mob_send_top_reached(int handle) { mob_send_event(handle, "top_reached"); } -static void mob_send_scrolled_past(int handle) { mob_send_event(handle, "scrolled_past"); } +static void mob_send_scroll_began(int handle) { + mob_send_event(handle, "scroll_began"); +} +static void mob_send_scroll_ended(int handle) { + mob_send_event(handle, "scroll_ended"); +} +static void mob_send_scroll_settled(int handle) { + mob_send_event(handle, "scroll_settled"); +} +static void mob_send_top_reached(int handle) { + mob_send_event(handle, "top_reached"); +} +static void mob_send_scrolled_past(int handle) { + mob_send_event(handle, "scrolled_past"); +} // ── Back gesture sender ─────────────────────────────────────────────────────── // Called from MobHostingController when the left-edge-pan gesture fires. @@ -515,12 +517,11 @@ static void mob_send_pointer_move(int handle, double x, double y) { // Non-static so Swift can call it via the bridging header. void mob_handle_back(void) { - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "mob"), - enif_make_atom(env, "back")); + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); enif_send(NULL, &pid, env, msg); } enif_free_env(env); @@ -535,21 +536,20 @@ static void mob_send_change(int handle, ERL_NIF_TERM value_term) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "change"), - enif_make_copy(msg_env, tag), - enif_make_copy(msg_env, value_term)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "change"), enif_make_copy(msg_env, tag), + enif_make_copy(msg_env, value_term)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_change_str(int handle, const char* utf8) { - ErlNifEnv* tmp = enif_alloc_env(); +static void mob_send_change_str(int handle, const char *utf8) { + ErlNifEnv *tmp = enif_alloc_env(); ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -560,14 +560,14 @@ static void mob_send_change_str(int handle, const char* utf8) { } static void mob_send_change_bool(int handle, int bool_val) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); mob_send_change(handle, term); enif_free_env(tmp); } static void mob_send_change_float(int handle, double value) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_double(tmp, value); mob_send_change(handle, term); enif_free_env(tmp); @@ -575,47 +575,69 @@ static void mob_send_change_float(int handle, double value) { // ── JSON → MobNode parser ───────────────────────────────────────────────────── -static UIColor* color_from_argb(long argb) { +static UIColor *color_from_argb(long argb) { CGFloat a = ((argb >> 24) & 0xFF) / 255.0; CGFloat r = ((argb >> 16) & 0xFF) / 255.0; - CGFloat g = ((argb >> 8) & 0xFF) / 255.0; - CGFloat b = ((argb >> 0) & 0xFF) / 255.0; + CGFloat g = ((argb >> 8) & 0xFF) / 255.0; + CGFloat b = ((argb >> 0) & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; } -static MobNode* mob_node_from_dict(NSDictionary* dict) { - if (![dict isKindOfClass:[NSDictionary class]]) return nil; - - MobNode* node = [[MobNode alloc] init]; - - NSString* type = dict[@"type"]; - if ([type isEqualToString:@"column"]) node.nodeType = MobNodeTypeColumn; - else if ([type isEqualToString:@"row"]) node.nodeType = MobNodeTypeRow; - else if ([type isEqualToString:@"text"] || - [type isEqualToString:@"label"]) node.nodeType = MobNodeTypeLabel; - else if ([type isEqualToString:@"button"]) node.nodeType = MobNodeTypeButton; - else if ([type isEqualToString:@"scroll"]) node.nodeType = MobNodeTypeScroll; - else if ([type isEqualToString:@"box"]) node.nodeType = MobNodeTypeBox; - else if ([type isEqualToString:@"divider"]) node.nodeType = MobNodeTypeDivider; - else if ([type isEqualToString:@"spacer"]) node.nodeType = MobNodeTypeSpacer; - else if ([type isEqualToString:@"progress"]) node.nodeType = MobNodeTypeProgress; - else if ([type isEqualToString:@"text_field"]) node.nodeType = MobNodeTypeTextField; - else if ([type isEqualToString:@"toggle"]) node.nodeType = MobNodeTypeToggle; - else if ([type isEqualToString:@"slider"]) node.nodeType = MobNodeTypeSlider; - else if ([type isEqualToString:@"image"]) node.nodeType = MobNodeTypeImage; - else if ([type isEqualToString:@"lazy_list"]) node.nodeType = MobNodeTypeLazyList; - else if ([type isEqualToString:@"tab_bar"]) node.nodeType = MobNodeTypeTabBar; - else if ([type isEqualToString:@"video"]) node.nodeType = MobNodeTypeVideo; - else if ([type isEqualToString:@"camera_preview"]) node.nodeType = MobNodeTypeCameraPreview; - else if ([type isEqualToString:@"web_view"]) node.nodeType = MobNodeTypeWebView; - else if ([type isEqualToString:@"native_view"]) node.nodeType = MobNodeTypeNativeView; - else if ([type isEqualToString:@"icon"]) node.nodeType = MobNodeTypeIcon; - else if ([type isEqualToString:@"canvas"]) node.nodeType = MobNodeTypeCanvas; - - NSDictionary* props = dict[@"props"]; +static MobNode *mob_node_from_dict(NSDictionary *dict) { + if (![dict isKindOfClass:[NSDictionary class]]) + return nil; + + MobNode *node = [[MobNode alloc] init]; + + NSString *type = dict[@"type"]; + if ([type isEqualToString:@"column"]) + node.nodeType = MobNodeTypeColumn; + else if ([type isEqualToString:@"row"]) + node.nodeType = MobNodeTypeRow; + else if ([type isEqualToString:@"text"] || [type isEqualToString:@"label"]) + node.nodeType = MobNodeTypeLabel; + else if ([type isEqualToString:@"button"]) + node.nodeType = MobNodeTypeButton; + else if ([type isEqualToString:@"scroll"]) + node.nodeType = MobNodeTypeScroll; + else if ([type isEqualToString:@"box"]) + node.nodeType = MobNodeTypeBox; + else if ([type isEqualToString:@"divider"]) + node.nodeType = MobNodeTypeDivider; + else if ([type isEqualToString:@"spacer"]) + node.nodeType = MobNodeTypeSpacer; + else if ([type isEqualToString:@"progress"]) + node.nodeType = MobNodeTypeProgress; + else if ([type isEqualToString:@"text_field"]) + node.nodeType = MobNodeTypeTextField; + else if ([type isEqualToString:@"toggle"]) + node.nodeType = MobNodeTypeToggle; + else if ([type isEqualToString:@"slider"]) + node.nodeType = MobNodeTypeSlider; + else if ([type isEqualToString:@"image"]) + node.nodeType = MobNodeTypeImage; + else if ([type isEqualToString:@"lazy_list"]) + node.nodeType = MobNodeTypeLazyList; + else if ([type isEqualToString:@"tab_bar"]) + node.nodeType = MobNodeTypeTabBar; + else if ([type isEqualToString:@"video"]) + node.nodeType = MobNodeTypeVideo; + else if ([type isEqualToString:@"camera_preview"]) + node.nodeType = MobNodeTypeCameraPreview; + else if ([type isEqualToString:@"web_view"]) + node.nodeType = MobNodeTypeWebView; + else if ([type isEqualToString:@"native_view"]) + node.nodeType = MobNodeTypeNativeView; + else if ([type isEqualToString:@"icon"]) + node.nodeType = MobNodeTypeIcon; + else if ([type isEqualToString:@"canvas"]) + node.nodeType = MobNodeTypeCanvas; + + NSDictionary *props = dict[@"props"]; if ([props isKindOfClass:[NSDictionary class]]) { id text = props[@"text"]; - if (text) node.text = [text isKindOfClass:[NSString class]] ? text : [text description]; + if (text) + node.text = [text isKindOfClass:[NSString class]] ? text : [text description]; // For text_field, `value:` is the controlled-input prop name (matches // the React/SwiftUI convention used in app code and demos). Map it @@ -623,74 +645,96 @@ static void mob_send_change_float(int handle, double value) { // `text:` and `value:` are passed, `value:` wins. if (node.nodeType == MobNodeTypeTextField) { id valueText = props[@"value"]; - if (valueText) node.text = [valueText isKindOfClass:[NSString class]] - ? valueText - : [valueText description]; + if (valueText) + node.text = [valueText isKindOfClass:[NSString class]] ? valueText + : [valueText description]; } id padding = props[@"padding"]; - if (padding) node.padding = [padding doubleValue]; + if (padding) + node.padding = [padding doubleValue]; id paddingTop = props[@"padding_top"]; - if (paddingTop) node.paddingTop = [paddingTop doubleValue]; + if (paddingTop) + node.paddingTop = [paddingTop doubleValue]; id paddingRight = props[@"padding_right"]; - if (paddingRight) node.paddingRight = [paddingRight doubleValue]; + if (paddingRight) + node.paddingRight = [paddingRight doubleValue]; id paddingBottom = props[@"padding_bottom"]; - if (paddingBottom) node.paddingBottom = [paddingBottom doubleValue]; + if (paddingBottom) + node.paddingBottom = [paddingBottom doubleValue]; id paddingLeft = props[@"padding_left"]; - if (paddingLeft) node.paddingLeft = [paddingLeft doubleValue]; + if (paddingLeft) + node.paddingLeft = [paddingLeft doubleValue]; id textSize = props[@"text_size"]; - if (textSize) node.textSize = [textSize doubleValue]; + if (textSize) + node.textSize = [textSize doubleValue]; id fontFamily = props[@"font"]; - if ([fontFamily isKindOfClass:[NSString class]]) node.fontFamily = fontFamily; + if ([fontFamily isKindOfClass:[NSString class]]) + node.fontFamily = fontFamily; id fontWeight = props[@"font_weight"]; - if (fontWeight) node.fontWeight = [fontWeight description]; + if (fontWeight) + node.fontWeight = [fontWeight description]; id textAlign = props[@"text_align"]; - if (textAlign) node.textAlign = [textAlign description]; + if (textAlign) + node.textAlign = [textAlign description]; id italic = props[@"italic"]; - if (italic) node.italic = [italic boolValue]; + if (italic) + node.italic = [italic boolValue]; id lineHeight = props[@"line_height"]; - if (lineHeight) node.lineHeight = [lineHeight doubleValue]; + if (lineHeight) + node.lineHeight = [lineHeight doubleValue]; id letterSpacing = props[@"letter_spacing"]; - if (letterSpacing) node.letterSpacing = [letterSpacing doubleValue]; + if (letterSpacing) + node.letterSpacing = [letterSpacing doubleValue]; id tabDefs = props[@"tabs"]; - if ([tabDefs isKindOfClass:[NSArray class]]) node.tabDefs = tabDefs; + if ([tabDefs isKindOfClass:[NSArray class]]) + node.tabDefs = tabDefs; id activeTab = props[@"active"]; - if (activeTab) node.activeTab = [activeTab description]; + if (activeTab) + node.activeTab = [activeTab description]; id onTabSelect = props[@"on_tab_select"]; if (onTabSelect && [onTabSelect isKindOfClass:[NSNumber class]]) { int handle = [onTabSelect intValue]; - node.onTabSelect = ^(NSString* tabId) { - mob_send_change_str(handle, [tabId UTF8String]); + node.onTabSelect = ^(NSString *tabId) { + mob_send_change_str(handle, [tabId UTF8String]); }; } id bg = props[@"background"]; - if (bg) node.backgroundColor = color_from_argb((long)[bg longLongValue]); + if (bg) + node.backgroundColor = color_from_argb((long)[bg longLongValue]); id borderColor = props[@"border_color"]; - if (borderColor) node.borderColor = color_from_argb((long)[borderColor longLongValue]); + if (borderColor) + node.borderColor = color_from_argb((long)[borderColor longLongValue]); id borderWidth = props[@"border_width"]; - if (borderWidth) node.borderWidth = [borderWidth doubleValue]; + if (borderWidth) + node.borderWidth = [borderWidth doubleValue]; id textColor = props[@"text_color"]; - if (textColor) node.textColor = color_from_argb((long)[textColor longLongValue]); + if (textColor) + node.textColor = color_from_argb((long)[textColor longLongValue]); id color = props[@"color"]; - if (color) node.color = color_from_argb((long)[color longLongValue]); + if (color) + node.color = color_from_argb((long)[color longLongValue]); id thickness = props[@"thickness"]; - if (thickness) node.thickness = [thickness doubleValue]; + if (thickness) + node.thickness = [thickness doubleValue]; id fixedSize = props[@"size"]; - if (fixedSize) node.fixedSize = [fixedSize doubleValue]; + if (fixedSize) + node.fixedSize = [fixedSize doubleValue]; id axis = props[@"axis"]; - if ([axis isKindOfClass:[NSString class]]) node.axis = axis; + if ([axis isKindOfClass:[NSString class]]) + node.axis = axis; // `align` plays two roles depending on node type — the Mob renderer // sets the same string and the iOS side picks the relevant @@ -702,143 +746,173 @@ static void mob_send_change_float(int handle, double value) { } id offsetX = props[@"offset_x"]; - if (offsetX) node.offsetX = [offsetX doubleValue]; + if (offsetX) + node.offsetX = [offsetX doubleValue]; id offsetY = props[@"offset_y"]; - if (offsetY) node.offsetY = [offsetY doubleValue]; + if (offsetY) + node.offsetY = [offsetY doubleValue]; id showIndicator = props[@"show_indicator"]; - if (showIndicator) node.showIndicator = [showIndicator boolValue]; + if (showIndicator) + node.showIndicator = [showIndicator boolValue]; id value = props[@"value"]; - if (value) node.value = [value doubleValue]; + if (value) + node.value = [value doubleValue]; id onTap = props[@"on_tap"]; if (onTap && [onTap isKindOfClass:[NSNumber class]]) { int handle = [onTap intValue]; - node.onTap = ^{ mob_send_tap(handle); }; + node.onTap = ^{ + mob_send_tap(handle); + }; } id placeholder = props[@"placeholder"]; - if (placeholder) node.placeholder = [placeholder isKindOfClass:[NSString class]] ? placeholder : [placeholder description]; + if (placeholder) + node.placeholder = [placeholder isKindOfClass:[NSString class]] + ? placeholder + : [placeholder description]; // Icon name — logical key (e.g. "settings"), resolved to an SF Symbol // by MobIconView at render time. iOS-only string parsing here. if (node.nodeType == MobNodeTypeIcon) { id iconName = props[@"name"]; - if (iconName) node.iconName = [iconName isKindOfClass:[NSString class]] - ? iconName - : [iconName description]; + if (iconName) + node.iconName = + [iconName isKindOfClass:[NSString class]] ? iconName : [iconName description]; } id keyboardType = props[@"keyboard"]; - if ([keyboardType isKindOfClass:[NSString class]]) node.keyboardTypeStr = keyboardType; + if ([keyboardType isKindOfClass:[NSString class]]) + node.keyboardTypeStr = keyboardType; id returnKey = props[@"return_key"]; - if ([returnKey isKindOfClass:[NSString class]]) node.returnKeyStr = returnKey; + if ([returnKey isKindOfClass:[NSString class]]) + node.returnKeyStr = returnKey; id onFocus = props[@"on_focus"]; if (onFocus && [onFocus isKindOfClass:[NSNumber class]]) { int handle = [onFocus intValue]; - node.onFocus = ^{ mob_send_focus(handle); }; + node.onFocus = ^{ + mob_send_focus(handle); + }; } id onBlur = props[@"on_blur"]; if (onBlur && [onBlur isKindOfClass:[NSNumber class]]) { int handle = [onBlur intValue]; - node.onBlur = ^{ mob_send_blur(handle); }; + node.onBlur = ^{ + mob_send_blur(handle); + }; } id onSubmit = props[@"on_submit"]; if (onSubmit && [onSubmit isKindOfClass:[NSNumber class]]) { int handle = [onSubmit intValue]; - node.onSubmit = ^{ mob_send_submit(handle); }; + node.onSubmit = ^{ + mob_send_submit(handle); + }; } id onCompose = props[@"on_compose"]; if (onCompose && [onCompose isKindOfClass:[NSNumber class]]) { int handle = [onCompose intValue]; - node.onCompose = ^(NSString* text, NSString* phase) { - mob_send_compose(handle, - text ? [text UTF8String] : "", - phase ? [phase UTF8String] : "updating"); + node.onCompose = ^(NSString *text, NSString *phase) { + mob_send_compose(handle, text ? [text UTF8String] : "", + phase ? [phase UTF8String] : "updating"); }; } id onSelect = props[@"on_select"]; if (onSelect && [onSelect isKindOfClass:[NSNumber class]]) { int handle = [onSelect intValue]; - node.onSelect = ^{ mob_send_select(handle); }; + node.onSelect = ^{ + mob_send_select(handle); + }; } // ── Gestures (Batch 4) ── id onLongPress = props[@"on_long_press"]; if (onLongPress && [onLongPress isKindOfClass:[NSNumber class]]) { int handle = [onLongPress intValue]; - node.onLongPress = ^{ mob_send_long_press(handle); }; + node.onLongPress = ^{ + mob_send_long_press(handle); + }; } id onDoubleTap = props[@"on_double_tap"]; if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) { int handle = [onDoubleTap intValue]; - node.onDoubleTap = ^{ mob_send_double_tap(handle); }; + node.onDoubleTap = ^{ + mob_send_double_tap(handle); + }; } id onSwipe = props[@"on_swipe"]; if (onSwipe && [onSwipe isKindOfClass:[NSNumber class]]) { int handle = [onSwipe intValue]; - node.onSwipe = ^(NSString* direction) { - mob_send_swipe_with_direction(handle, [direction UTF8String]); + node.onSwipe = ^(NSString *direction) { + mob_send_swipe_with_direction(handle, [direction UTF8String]); }; } id onSwipeLeft = props[@"on_swipe_left"]; if (onSwipeLeft && [onSwipeLeft isKindOfClass:[NSNumber class]]) { int handle = [onSwipeLeft intValue]; - node.onSwipeLeft = ^{ mob_send_swipe_left(handle); }; + node.onSwipeLeft = ^{ + mob_send_swipe_left(handle); + }; } id onSwipeRight = props[@"on_swipe_right"]; if (onSwipeRight && [onSwipeRight isKindOfClass:[NSNumber class]]) { int handle = [onSwipeRight intValue]; - node.onSwipeRight = ^{ mob_send_swipe_right(handle); }; + node.onSwipeRight = ^{ + mob_send_swipe_right(handle); + }; } id onSwipeUp = props[@"on_swipe_up"]; if (onSwipeUp && [onSwipeUp isKindOfClass:[NSNumber class]]) { int handle = [onSwipeUp intValue]; - node.onSwipeUp = ^{ mob_send_swipe_up(handle); }; + node.onSwipeUp = ^{ + mob_send_swipe_up(handle); + }; } id onSwipeDown = props[@"on_swipe_down"]; if (onSwipeDown && [onSwipeDown isKindOfClass:[NSNumber class]]) { int handle = [onSwipeDown intValue]; - node.onSwipeDown = ^{ mob_send_swipe_down(handle); }; + node.onSwipeDown = ^{ + mob_send_swipe_down(handle); + }; } - // ── Batch 5 Tier 1: high-frequency events (with throttle config) ── - // Helper macro: read a *_config sibling prop and apply it to the - // handle's throttle state. - #define MOB_APPLY_THROTTLE(HANDLE, CONFIG_KEY) \ - do { \ - id _cfg = props[CONFIG_KEY]; \ - if ([_cfg isKindOfClass:[NSDictionary class]]) { \ - int t = [(_cfg[@"throttle_ms"] ?: @0) intValue]; \ - int d = [(_cfg[@"debounce_ms"] ?: @0) intValue]; \ - double dt = [(_cfg[@"delta_threshold"] ?: @0) doubleValue]; \ - int ld = [(_cfg[@"leading"] ?: @YES) boolValue] ? 1 : 0; \ - int tr = [(_cfg[@"trailing"] ?: @YES) boolValue] ? 1 : 0; \ - mob_set_throttle_config((HANDLE), t, d, dt, ld, tr); \ - } \ - } while (0) +// ── Batch 5 Tier 1: high-frequency events (with throttle config) ── +// Helper macro: read a *_config sibling prop and apply it to the +// handle's throttle state. +#define MOB_APPLY_THROTTLE(HANDLE, CONFIG_KEY) \ + do { \ + id _cfg = props[CONFIG_KEY]; \ + if ([_cfg isKindOfClass:[NSDictionary class]]) { \ + int t = [(_cfg[@"throttle_ms"] ?: @0) intValue]; \ + int d = [(_cfg[@"debounce_ms"] ?: @0) intValue]; \ + double dt = [(_cfg[@"delta_threshold"] ?: @0) doubleValue]; \ + int ld = [(_cfg[@"leading"] ?: @YES) boolValue] ? 1 : 0; \ + int tr = [(_cfg[@"trailing"] ?: @YES) boolValue] ? 1 : 0; \ + mob_set_throttle_config((HANDLE), t, d, dt, ld, tr); \ + } \ + } while (0) id onScroll = props[@"on_scroll"]; if ([onScroll isKindOfClass:[NSNumber class]]) { int handle = [onScroll intValue]; MOB_APPLY_THROTTLE(handle, @"scroll_config"); - node.onScroll = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, - CGFloat vx, CGFloat vy, NSString* phase) { - mob_send_scroll(handle, x, y, dx, dy, vx, vy, - phase ? [phase UTF8String] : "dragging"); + node.onScroll = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, CGFloat vx, CGFloat vy, + NSString *phase) { + mob_send_scroll(handle, x, y, dx, dy, vx, vy, + phase ? [phase UTF8String] : "dragging"); }; } @@ -846,9 +920,8 @@ static void mob_send_change_float(int handle, double value) { if ([onDrag isKindOfClass:[NSNumber class]]) { int handle = [onDrag intValue]; MOB_APPLY_THROTTLE(handle, @"drag_config"); - node.onDrag = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString* phase) { - mob_send_drag(handle, x, y, dx, dy, - phase ? [phase UTF8String] : "dragging"); + node.onDrag = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString *phase) { + mob_send_drag(handle, x, y, dx, dy, phase ? [phase UTF8String] : "dragging"); }; } @@ -856,9 +929,8 @@ static void mob_send_change_float(int handle, double value) { if ([onPinch isKindOfClass:[NSNumber class]]) { int handle = [onPinch intValue]; MOB_APPLY_THROTTLE(handle, @"pinch_config"); - node.onPinch = ^(CGFloat scale, CGFloat velocity, NSString* phase) { - mob_send_pinch(handle, scale, velocity, - phase ? [phase UTF8String] : "dragging"); + node.onPinch = ^(CGFloat scale, CGFloat velocity, NSString *phase) { + mob_send_pinch(handle, scale, velocity, phase ? [phase UTF8String] : "dragging"); }; } @@ -866,9 +938,8 @@ static void mob_send_change_float(int handle, double value) { if ([onRotate isKindOfClass:[NSNumber class]]) { int handle = [onRotate intValue]; MOB_APPLY_THROTTLE(handle, @"rotate_config"); - node.onRotate = ^(CGFloat degrees, CGFloat velocity, NSString* phase) { - mob_send_rotate(handle, degrees, velocity, - phase ? [phase UTF8String] : "dragging"); + node.onRotate = ^(CGFloat degrees, CGFloat velocity, NSString *phase) { + mob_send_rotate(handle, degrees, velocity, phase ? [phase UTF8String] : "dragging"); }; } @@ -877,41 +948,51 @@ static void mob_send_change_float(int handle, double value) { int handle = [onPointerMove intValue]; MOB_APPLY_THROTTLE(handle, @"pointer_config"); node.onPointerMove = ^(CGFloat x, CGFloat y) { - mob_send_pointer_move(handle, x, y); + mob_send_pointer_move(handle, x, y); }; } - #undef MOB_APPLY_THROTTLE +#undef MOB_APPLY_THROTTLE // ── Batch 5 Tier 2: semantic single-fire scroll events ── id onScrollBegan = props[@"on_scroll_began"]; if ([onScrollBegan isKindOfClass:[NSNumber class]]) { int handle = [onScrollBegan intValue]; - node.onScrollBegan = ^{ mob_send_scroll_began(handle); }; + node.onScrollBegan = ^{ + mob_send_scroll_began(handle); + }; } id onScrollEnded = props[@"on_scroll_ended"]; if ([onScrollEnded isKindOfClass:[NSNumber class]]) { int handle = [onScrollEnded intValue]; - node.onScrollEnded = ^{ mob_send_scroll_ended(handle); }; + node.onScrollEnded = ^{ + mob_send_scroll_ended(handle); + }; } id onScrollSettled = props[@"on_scroll_settled"]; if ([onScrollSettled isKindOfClass:[NSNumber class]]) { int handle = [onScrollSettled intValue]; - node.onScrollSettled = ^{ mob_send_scroll_settled(handle); }; + node.onScrollSettled = ^{ + mob_send_scroll_settled(handle); + }; } id onTopReached = props[@"on_top_reached"]; if ([onTopReached isKindOfClass:[NSNumber class]]) { int handle = [onTopReached intValue]; - node.onTopReached = ^{ mob_send_top_reached(handle); }; + node.onTopReached = ^{ + mob_send_top_reached(handle); + }; } id onScrolledPast = props[@"on_scrolled_past"]; if ([onScrolledPast isKindOfClass:[NSNumber class]]) { int handle = [onScrolledPast intValue]; - node.onScrolledPast = ^{ mob_send_scrolled_past(handle); }; + node.onScrolledPast = ^{ + mob_send_scrolled_past(handle); + }; } id scrolledPastThreshold = props[@"scrolled_past_threshold"]; if (scrolledPastThreshold) { @@ -941,76 +1022,103 @@ static void mob_send_change_float(int handle, double value) { } id minVal = props[@"min"]; - if (minVal) node.minValue = [minVal doubleValue]; + if (minVal) + node.minValue = [minVal doubleValue]; id maxVal = props[@"max"]; - if (maxVal) node.maxValue = [maxVal doubleValue]; + if (maxVal) + node.maxValue = [maxVal doubleValue]; id src = props[@"src"]; - if ([src isKindOfClass:[NSString class]]) node.src = src; + if ([src isKindOfClass:[NSString class]]) + node.src = src; id contentMode = props[@"content_mode"]; - if ([contentMode isKindOfClass:[NSString class]]) node.contentModeStr = contentMode; + if ([contentMode isKindOfClass:[NSString class]]) + node.contentModeStr = contentMode; id fixedWidth = props[@"width"]; - if (fixedWidth) node.fixedWidth = [fixedWidth doubleValue]; + if (fixedWidth) + node.fixedWidth = [fixedWidth doubleValue]; id fixedHeight = props[@"height"]; - if (fixedHeight) node.fixedHeight = [fixedHeight doubleValue]; + if (fixedHeight) + node.fixedHeight = [fixedHeight doubleValue]; id cornerRadius = props[@"corner_radius"]; - if (cornerRadius) node.cornerRadius = [cornerRadius doubleValue]; + if (cornerRadius) + node.cornerRadius = [cornerRadius doubleValue]; id fillWidth = props[@"fill_width"]; - if (fillWidth) node.fillWidth = [fillWidth boolValue]; + if (fillWidth) + node.fillWidth = [fillWidth boolValue]; id fillHeight = props[@"fill_height"]; - if (fillHeight) node.fillHeight = [fillHeight boolValue]; + if (fillHeight) + node.fillHeight = [fillHeight boolValue]; id placeholderColor = props[@"placeholder_color"]; - if (placeholderColor) node.placeholderColor = color_from_argb((long)[placeholderColor longLongValue]); + if (placeholderColor) + node.placeholderColor = color_from_argb((long)[placeholderColor longLongValue]); id videoAutoplay = props[@"autoplay"]; - if (videoAutoplay) node.videoAutoplay = [videoAutoplay boolValue]; + if (videoAutoplay) + node.videoAutoplay = [videoAutoplay boolValue]; id videoLoop = props[@"loop"]; - if (videoLoop) node.videoLoop = [videoLoop boolValue]; + if (videoLoop) + node.videoLoop = [videoLoop boolValue]; id videoControls = props[@"controls"]; - if (videoControls) node.videoControls = [videoControls boolValue]; + if (videoControls) + node.videoControls = [videoControls boolValue]; id cameraFacing = props[@"facing"]; - if ([cameraFacing isKindOfClass:[NSString class]]) node.cameraFacing = cameraFacing; + if ([cameraFacing isKindOfClass:[NSString class]]) + node.cameraFacing = cameraFacing; // canvas props id canvasDraw = props[@"draw"]; - if ([canvasDraw isKindOfClass:[NSArray class]]) node.canvasOps = canvasDraw; + if ([canvasDraw isKindOfClass:[NSArray class]]) + node.canvasOps = canvasDraw; id canvasW = props[@"width"]; - if (canvasW && node.nodeType == MobNodeTypeCanvas) node.canvasWidth = [canvasW doubleValue]; + if (canvasW && node.nodeType == MobNodeTypeCanvas) + node.canvasWidth = [canvasW doubleValue]; id canvasH = props[@"height"]; - if (canvasH && node.nodeType == MobNodeTypeCanvas) node.canvasHeight = [canvasH doubleValue]; + if (canvasH && node.nodeType == MobNodeTypeCanvas) + node.canvasHeight = [canvasH doubleValue]; // webview props id webViewUrl = props[@"url"]; - if ([webViewUrl isKindOfClass:[NSString class]]) node.webViewUrl = webViewUrl; + if ([webViewUrl isKindOfClass:[NSString class]]) + node.webViewUrl = webViewUrl; id webViewAllow = props[@"allow"]; - if ([webViewAllow isKindOfClass:[NSString class]]) node.webViewAllow = webViewAllow; + if ([webViewAllow isKindOfClass:[NSString class]]) + node.webViewAllow = webViewAllow; id webViewShowUrl = props[@"show_url"]; - if (webViewShowUrl) node.webViewShowUrl = [webViewShowUrl boolValue]; + if (webViewShowUrl) + node.webViewShowUrl = [webViewShowUrl boolValue]; id webViewTitle = props[@"title"]; - if ([webViewTitle isKindOfClass:[NSString class]]) node.webViewTitle = webViewTitle; + if ([webViewTitle isKindOfClass:[NSString class]]) + node.webViewTitle = webViewTitle; // native_view props id nativeViewModule = props[@"module"]; - if ([nativeViewModule isKindOfClass:[NSString class]]) node.nativeViewModule = nativeViewModule; + if ([nativeViewModule isKindOfClass:[NSString class]]) + node.nativeViewModule = nativeViewModule; id nativeViewId = props[@"id"]; - if ([nativeViewId isKindOfClass:[NSString class]]) node.nativeViewId = nativeViewId; + if ([nativeViewId isKindOfClass:[NSString class]]) + node.nativeViewId = nativeViewId; id nativeViewHandle = props[@"component_handle"]; - if (nativeViewHandle) node.nativeViewHandle = [nativeViewHandle intValue]; - if (node.nodeType == MobNodeTypeNativeView) node.nativeViewProps = props; + if (nativeViewHandle) + node.nativeViewHandle = [nativeViewHandle intValue]; + if (node.nodeType == MobNodeTypeNativeView) + node.nativeViewProps = props; id onEndReached = props[@"on_end_reached"]; if (onEndReached && [onEndReached isKindOfClass:[NSNumber class]]) { int handle = [onEndReached intValue]; - node.onTap = ^{ mob_send_tap(handle); }; + node.onTap = ^{ + mob_send_tap(handle); + }; } // For slider, value is the initial position (re-uses node.value property) @@ -1020,17 +1128,23 @@ static void mob_send_change_float(int handle, double value) { if (onChange && [onChange isKindOfClass:[NSNumber class]]) { int handle = [onChange intValue]; switch (node.nodeType) { - case MobNodeTypeTextField: - node.onChangeStr = ^(NSString* v) { mob_send_change_str(handle, [v UTF8String]); }; - break; - case MobNodeTypeToggle: - node.onChangeBool = ^(BOOL v) { mob_send_change_bool(handle, (int)v); }; - break; - case MobNodeTypeSlider: - node.onChangeFloat = ^(double v) { mob_send_change_float(handle, v); }; - break; - default: - break; + case MobNodeTypeTextField: + node.onChangeStr = ^(NSString *v) { + mob_send_change_str(handle, [v UTF8String]); + }; + break; + case MobNodeTypeToggle: + node.onChangeBool = ^(BOOL v) { + mob_send_change_bool(handle, (int)v); + }; + break; + case MobNodeTypeSlider: + node.onChangeFloat = ^(double v) { + mob_send_change_float(handle, v); + }; + break; + default: + break; } } @@ -1040,11 +1154,12 @@ static void mob_send_change_float(int handle, double value) { } } - NSArray* children = dict[@"children"]; + NSArray *children = dict[@"children"]; if ([children isKindOfClass:[NSArray class]]) { for (id child in children) { - MobNode* childNode = mob_node_from_dict(child); - if (childNode) [node.children addObject:childNode]; + MobNode *childNode = mob_node_from_dict(child); + if (childNode) + [node.children addObject:childNode]; } } @@ -1056,13 +1171,13 @@ static void mob_send_change_float(int handle, double value) { // handled by the OS. This is intentionally a no-op; backgrounding on iOS // happens naturally when the user swipes up. -static ERL_NIF_TERM nif_exit_app(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_exit_app(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "ok"); } // ── NIF: platform/0 ────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "ios"); } @@ -1070,31 +1185,40 @@ static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // Returns :light or :dark based on UIUserInterfaceStyle. // Falls back to :light when called before any window is on screen. -static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIUserInterfaceStyle style = UIUserInterfaceStyleUnspecified; void (^read)(void) = ^{ - // Prefer the key window's trait collection (most accurate once the - // app is on screen). Fall back to UITraitCollection.current (set - // during a render pass) and finally UIScreen.mainScreen for the - // earliest startup edge case before any window exists. - UIWindow *win = nil; - for (UIWindowScene *scene in [UIApplication.sharedApplication.connectedScenes allObjects]) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in scene.windows) { - if (w.isKeyWindow) { win = w; break; } - } - if (win) break; - } - if (win) { - style = win.traitCollection.userInterfaceStyle; - } else { - UIUserInterfaceStyle current_s = UITraitCollection.currentTraitCollection.userInterfaceStyle; - style = (current_s != UIUserInterfaceStyleUnspecified) - ? current_s - : UIScreen.mainScreen.traitCollection.userInterfaceStyle; - } + // Prefer the key window's trait collection (most accurate once the + // app is on screen). Fall back to UITraitCollection.current (set + // during a render pass) and finally UIScreen.mainScreen for the + // earliest startup edge case before any window exists. + UIWindow *win = nil; + for (UIWindowScene *scene in [UIApplication.sharedApplication.connectedScenes allObjects]) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in scene.windows) { + if (w.isKeyWindow) { + win = w; + break; + } + } + if (win) + break; + } + if (win) { + style = win.traitCollection.userInterfaceStyle; + } else { + UIUserInterfaceStyle current_s = + UITraitCollection.currentTraitCollection.userInterfaceStyle; + style = (current_s != UIUserInterfaceStyleUnspecified) + ? current_s + : UIScreen.mainScreen.traitCollection.userInterfaceStyle; + } }; - if ([NSThread isMainThread]) read(); else dispatch_sync(dispatch_get_main_queue(), read); + if ([NSThread isMainThread]) + read(); + else + dispatch_sync(dispatch_get_main_queue(), read); return enif_make_atom(env, style == UIUserInterfaceStyleDark ? "dark" : "light"); } @@ -1117,13 +1241,15 @@ static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TER // // Requires UIBackgroundModes: [audio] in the app's Info.plist. -static AVAudioEngine *g_keep_alive_engine = nil; -static AVAudioPlayerNode *g_keep_alive_player = nil; -static BOOL g_keep_alive_active = NO; // user intent: should be running -static id g_keep_alive_interruption_observer = nil; // token from addObserverForName, needed for removeObserver +static AVAudioEngine *g_keep_alive_engine = nil; +static AVAudioPlayerNode *g_keep_alive_player = nil; +static BOOL g_keep_alive_active = NO; // user intent: should be running +static id g_keep_alive_interruption_observer = + nil; // token from addObserverForName, needed for removeObserver static void keep_alive_start_engine(void) { - if (g_keep_alive_engine != nil) return; + if (g_keep_alive_engine != nil) + return; @try { NSError *err = nil; @@ -1146,15 +1272,14 @@ static void keep_alive_start_engine(void) { // Use the mixer's native format so connect: and the buffer agree — // a format mismatch here throws NSInvalidArgumentException, which // takes down the BEAM scheduler thread. - AVAudioFormat *fmt = - [g_keep_alive_engine.mainMixerNode outputFormatForBus:0]; + AVAudioFormat *fmt = [g_keep_alive_engine.mainMixerNode outputFormatForBus:0]; [g_keep_alive_engine connect:g_keep_alive_player to:g_keep_alive_engine.mainMixerNode format:fmt]; AVAudioFrameCount frames = (AVAudioFrameCount)fmt.sampleRate; - AVAudioPCMBuffer *buf = [[AVAudioPCMBuffer alloc] - initWithPCMFormat:fmt frameCapacity:frames]; + AVAudioPCMBuffer *buf = [[AVAudioPCMBuffer alloc] initWithPCMFormat:fmt + frameCapacity:frames]; buf.frameLength = frames; // Engine must be running before scheduleBuffer/play. @@ -1170,10 +1295,9 @@ static void keep_alive_start_engine(void) { options:AVAudioPlayerNodeBufferLoops completionHandler:nil]; [g_keep_alive_player play]; - NSLog(@"[mob] keep_alive engine running (sampleRate=%.0f, channels=%u)", - fmt.sampleRate, (unsigned)fmt.channelCount); - } - @catch (NSException *ex) { + NSLog(@"[mob] keep_alive engine running (sampleRate=%.0f, channels=%u)", fmt.sampleRate, + (unsigned)fmt.channelCount); + } @catch (NSException *ex) { NSLog(@"[mob] keep_alive exception: %@ — %@", ex.name, ex.reason); g_keep_alive_engine = nil; g_keep_alive_player = nil; @@ -1181,54 +1305,61 @@ static void keep_alive_start_engine(void) { } static void keep_alive_stop_engine(void) { - if (g_keep_alive_player) { [g_keep_alive_player stop]; g_keep_alive_player = nil; } - if (g_keep_alive_engine) { [g_keep_alive_engine stop]; g_keep_alive_engine = nil; } + if (g_keep_alive_player) { + [g_keep_alive_player stop]; + g_keep_alive_player = nil; + } + if (g_keep_alive_engine) { + [g_keep_alive_engine stop]; + g_keep_alive_engine = nil; + } } -static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // Async so the BEAM scheduler isn't blocked while AVFoundation initialises // (which can throw an NSException and take down the scheduler thread). dispatch_async(dispatch_get_main_queue(), ^{ - if (g_keep_alive_active) return; // idempotent - g_keep_alive_active = YES; - - // Restart engine after audio session interruptions (e.g. recording ends, - // phone call ends). InterruptionTypeEnded fires when the session is ours - // again; we reconfigure and resume the silence loop. - // Stash the returned token — block-based observers must be removed - // by their token, not by name (passing nil to removeObserver: is - // a no-op for the block API). - g_keep_alive_interruption_observer = [[NSNotificationCenter defaultCenter] - addObserverForName:AVAudioSessionInterruptionNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *note) { - if (!g_keep_alive_active) return; - AVAudioSessionInterruptionType type = - [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; - if (type == AVAudioSessionInterruptionTypeBegan) { - keep_alive_stop_engine(); - } else { - // InterruptionTypeEnded — real audio finished, reclaim the session. - keep_alive_start_engine(); - } - }]; + if (g_keep_alive_active) + return; // idempotent + g_keep_alive_active = YES; + + // Restart engine after audio session interruptions (e.g. recording ends, + // phone call ends). InterruptionTypeEnded fires when the session is ours + // again; we reconfigure and resume the silence loop. + // Stash the returned token — block-based observers must be removed + // by their token, not by name (passing nil to removeObserver: is + // a no-op for the block API). + g_keep_alive_interruption_observer = [[NSNotificationCenter defaultCenter] + addObserverForName:AVAudioSessionInterruptionNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + if (!g_keep_alive_active) + return; + AVAudioSessionInterruptionType type = + [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + if (type == AVAudioSessionInterruptionTypeBegan) { + keep_alive_stop_engine(); + } else { + // InterruptionTypeEnded — real audio finished, reclaim the session. + keep_alive_start_engine(); + } + }]; - keep_alive_start_engine(); + keep_alive_start_engine(); }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_background_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - g_keep_alive_active = NO; - if (g_keep_alive_interruption_observer) { - [[NSNotificationCenter defaultCenter] - removeObserver:g_keep_alive_interruption_observer]; - g_keep_alive_interruption_observer = nil; - } - keep_alive_stop_engine(); - [[AVAudioSession sharedInstance] + g_keep_alive_active = NO; + if (g_keep_alive_interruption_observer) { + [[NSNotificationCenter defaultCenter] removeObserver:g_keep_alive_interruption_observer]; + g_keep_alive_interruption_observer = nil; + } + keep_alive_stop_engine(); + [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; @@ -1238,17 +1369,17 @@ static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_ // ── NIF: battery_level/0 ───────────────────────────────────────────────────── -static ERL_NIF_TERM nif_battery_level(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_battery_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block int level = -1; dispatch_sync(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) { - dev.batteryMonitoringEnabled = YES; - } - float f = dev.batteryLevel; - if (f >= 0.0f) { - level = (int)roundf(f * 100.0f); - } + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) { + dev.batteryMonitoringEnabled = YES; + } + float f = dev.batteryLevel; + if (f >= 0.0f) { + level = (int)roundf(f * 100.0f); + } }); return enif_make_int(env, level); } @@ -1268,27 +1399,26 @@ static ERL_NIF_TERM nif_battery_level(ErlNifEnv* env, int argc, const ERL_NIF_TE // re-register observers (avoids duplicate notifications). static ErlNifPid g_device_dispatcher_pid; -static BOOL g_device_dispatcher_set = NO; +static BOOL g_device_dispatcher_set = NO; static dispatch_once_t g_device_observers_once = 0; static void mob_device_send_atom(const char *tag, const char *atom_name) { - if (!g_device_dispatcher_set) return; + if (!g_device_dispatcher_set) + return; ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name)); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name)); enif_send(NULL, &g_device_dispatcher_pid, e, msg); enif_free_env(e); } -static void mob_device_send_atom_payload(const char *tag, const char *atom_name, ERL_NIF_TERM payload, ErlNifEnv *payload_env) { - if (!g_device_dispatcher_set) return; +static void mob_device_send_atom_payload(const char *tag, const char *atom_name, + ERL_NIF_TERM payload, ErlNifEnv *payload_env) { + if (!g_device_dispatcher_set) + return; ErlNifEnv *e = enif_alloc_env(); ERL_NIF_TERM payload_copy = enif_make_copy(e, payload); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name), - payload_copy); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name), payload_copy); enif_send(NULL, &g_device_dispatcher_pid, e, msg); enif_free_env(e); (void)payload_env; @@ -1296,142 +1426,186 @@ static void mob_device_send_atom_payload(const char *tag, const char *atom_name, static const char *thermal_state_atom(NSProcessInfoThermalState s) { switch (s) { - case NSProcessInfoThermalStateNominal: return "nominal"; - case NSProcessInfoThermalStateFair: return "fair"; - case NSProcessInfoThermalStateSerious: return "serious"; - case NSProcessInfoThermalStateCritical: return "critical"; - default: return "nominal"; + case NSProcessInfoThermalStateNominal: + return "nominal"; + case NSProcessInfoThermalStateFair: + return "fair"; + case NSProcessInfoThermalStateSerious: + return "serious"; + case NSProcessInfoThermalStateCritical: + return "critical"; + default: + return "nominal"; } } static const char *battery_state_atom(UIDeviceBatteryState s) { switch (s) { - case UIDeviceBatteryStateUnplugged: return "unplugged"; - case UIDeviceBatteryStateCharging: return "charging"; - case UIDeviceBatteryStateFull: return "full"; - default: return "unknown"; + case UIDeviceBatteryStateUnplugged: + return "unplugged"; + case UIDeviceBatteryStateCharging: + return "charging"; + case UIDeviceBatteryStateFull: + return "full"; + default: + return "unknown"; } } static void register_device_observers_once(void) { dispatch_once(&g_device_observers_once, ^{ - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - NSOperationQueue *q = [NSOperationQueue mainQueue]; - - // ── App lifecycle ── - [nc addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_resign_active"); - mob_device_send_atom("mob_device_ios", "will_resign_active"); - }]; - [nc addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "did_become_active"); - mob_device_send_atom("mob_device_ios", "did_become_active"); - }]; - [nc addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "did_enter_background"); - mob_device_send_atom("mob_device_ios", "did_enter_background"); - }]; - [nc addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_enter_foreground"); - mob_device_send_atom("mob_device_ios", "will_enter_foreground"); - }]; - [nc addObserverForName:UIApplicationWillTerminateNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_terminate"); - mob_device_send_atom("mob_device_ios", "will_terminate"); - }]; - [nc addObserverForName:UIApplicationDidReceiveMemoryWarningNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "memory_warning"); - mob_device_send_atom("mob_device_ios", "memory_warning"); - }]; - - // ── Display / lock state (iOS proxies via data-protection) ── - [nc addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable - object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "screen_off"); - mob_device_send_atom("mob_device_ios", "protected_data_will_become_unavailable"); - }]; - [nc addObserverForName:UIApplicationProtectedDataDidBecomeAvailable - object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "screen_on"); - mob_device_send_atom("mob_device_ios", "protected_data_did_become_available"); - }]; - - // ── Power / thermal ── - [nc addObserverForName:NSProcessInfoThermalStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - const char *s = thermal_state_atom([[NSProcessInfo processInfo] thermalState]); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, s); - mob_device_send_atom_payload("mob_device", "thermal_state_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "thermal_state_changed", payload, e); - enif_free_env(e); - }]; - [nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - BOOL low = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, low ? "true" : "false"); - mob_device_send_atom_payload("mob_device", "low_power_mode_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "low_power_mode_changed", payload, e); - enif_free_env(e); - }]; - - // Ensure battery monitoring is on so the change notifications fire. - dispatch_async(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) dev.batteryMonitoringEnabled = YES; - }); - [nc addObserverForName:UIDeviceBatteryStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - const char *s = battery_state_atom([[UIDevice currentDevice] batteryState]); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, s); - mob_device_send_atom_payload("mob_device", "battery_state_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "battery_state_changed", payload, e); - enif_free_env(e); - }]; - [nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - float lvl = [[UIDevice currentDevice] batteryLevel]; - int pct = lvl >= 0.0f ? (int)roundf(lvl * 100.0f) : -1; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_int(e, pct); - mob_device_send_atom_payload("mob_device", "battery_level_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "battery_level_changed", payload, e); - enif_free_env(e); - }]; - - // ── Audio session interruptions / route changes ── - [nc addObserverForName:AVAudioSessionInterruptionNotification object:nil queue:q - usingBlock:^(NSNotification *note) { - AVAudioSessionInterruptionType t = - [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; - const char *atom = (t == AVAudioSessionInterruptionTypeBegan) - ? "audio_interrupted" : "audio_resumed"; - mob_device_send_atom("mob_device", atom); - mob_device_send_atom("mob_device_ios", atom); - }]; - [nc addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:q - usingBlock:^(NSNotification *note) { - mob_device_send_atom("mob_device", "audio_route_changed"); - mob_device_send_atom("mob_device_ios", "audio_route_changed"); - }]; - - NSLog(@"[mob] Mob.Device observers registered"); + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + NSOperationQueue *q = [NSOperationQueue mainQueue]; + + // ── App lifecycle ── + [nc addObserverForName:UIApplicationWillResignActiveNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_resign_active"); + mob_device_send_atom("mob_device_ios", "will_resign_active"); + }]; + [nc addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "did_become_active"); + mob_device_send_atom("mob_device_ios", "did_become_active"); + }]; + [nc addObserverForName:UIApplicationDidEnterBackgroundNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "did_enter_background"); + mob_device_send_atom("mob_device_ios", "did_enter_background"); + }]; + [nc addObserverForName:UIApplicationWillEnterForegroundNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_enter_foreground"); + mob_device_send_atom("mob_device_ios", "will_enter_foreground"); + }]; + [nc addObserverForName:UIApplicationWillTerminateNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_terminate"); + mob_device_send_atom("mob_device_ios", "will_terminate"); + }]; + [nc addObserverForName:UIApplicationDidReceiveMemoryWarningNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "memory_warning"); + mob_device_send_atom("mob_device_ios", "memory_warning"); + }]; + + // ── Display / lock state (iOS proxies via data-protection) ── + [nc addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "screen_off"); + mob_device_send_atom("mob_device_ios", + "protected_data_will_become_unavailable"); + }]; + [nc addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "screen_on"); + mob_device_send_atom("mob_device_ios", "protected_data_did_become_available"); + }]; + + // ── Power / thermal ── + [nc addObserverForName:NSProcessInfoThermalStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + const char *s = thermal_state_atom([[NSProcessInfo processInfo] thermalState]); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, s); + mob_device_send_atom_payload("mob_device", "thermal_state_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "thermal_state_changed", payload, + e); + enif_free_env(e); + }]; + [nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + BOOL low = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, low ? "true" : "false"); + mob_device_send_atom_payload("mob_device", "low_power_mode_changed", payload, + e); + mob_device_send_atom_payload("mob_device_ios", "low_power_mode_changed", + payload, e); + enif_free_env(e); + }]; + + // Ensure battery monitoring is on so the change notifications fire. + dispatch_async(dispatch_get_main_queue(), ^{ + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) + dev.batteryMonitoringEnabled = YES; + }); + [nc addObserverForName:UIDeviceBatteryStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + const char *s = battery_state_atom([[UIDevice currentDevice] batteryState]); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, s); + mob_device_send_atom_payload("mob_device", "battery_state_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "battery_state_changed", payload, + e); + enif_free_env(e); + }]; + [nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + float lvl = [[UIDevice currentDevice] batteryLevel]; + int pct = lvl >= 0.0f ? (int)roundf(lvl * 100.0f) : -1; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_int(e, pct); + mob_device_send_atom_payload("mob_device", "battery_level_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "battery_level_changed", payload, + e); + enif_free_env(e); + }]; + + // ── Audio session interruptions / route changes ── + [nc addObserverForName:AVAudioSessionInterruptionNotification + object:nil + queue:q + usingBlock:^(NSNotification *note) { + AVAudioSessionInterruptionType t = + [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + const char *atom = (t == AVAudioSessionInterruptionTypeBegan) + ? "audio_interrupted" + : "audio_resumed"; + mob_device_send_atom("mob_device", atom); + mob_device_send_atom("mob_device_ios", atom); + }]; + [nc addObserverForName:AVAudioSessionRouteChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *note) { + mob_device_send_atom("mob_device", "audio_route_changed"); + mob_device_send_atom("mob_device_ios", "audio_route_changed"); + }]; + + NSLog(@"[mob] Mob.Device observers registered"); }); } static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); + if (!enif_get_local_pid(env, argv[0], &pid)) + return enif_make_badarg(env); g_device_dispatcher_pid = pid; g_device_dispatcher_set = YES; register_device_observers_once(); @@ -1446,10 +1620,11 @@ static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ER // subscribers without polling. Use this rather than UITraitChange APIs because // SwiftUI handles iOS 13–17 compatibility for us. void mob_notify_color_scheme(const char *scheme) { - if (!g_device_dispatcher_set || !scheme) return; + if (!g_device_dispatcher_set || !scheme) + return; ErlNifEnv *e = enif_alloc_env(); ERL_NIF_TERM payload = enif_make_atom(e, scheme); - mob_device_send_atom_payload("mob_device", "color_scheme_changed", payload, e); + mob_device_send_atom_payload("mob_device", "color_scheme_changed", payload, e); mob_device_send_atom_payload("mob_device_ios", "color_scheme_changed", payload, e); enif_free_env(e); } @@ -1458,15 +1633,16 @@ static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv *env, int argc, const ERL __block UIDeviceBatteryState s = UIDeviceBatteryStateUnknown; __block int pct = -1; dispatch_sync(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) dev.batteryMonitoringEnabled = YES; - s = dev.batteryState; - float f = dev.batteryLevel; - if (f >= 0.0f) pct = (int)roundf(f * 100.0f); + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) + dev.batteryMonitoringEnabled = YES; + s = dev.batteryState; + float f = dev.batteryLevel; + if (f >= 0.0f) + pct = (int)roundf(f * 100.0f); }); - return enif_make_tuple2(env, - enif_make_atom(env, battery_state_atom(s)), - enif_make_int(env, pct)); + return enif_make_tuple2(env, enif_make_atom(env, battery_state_atom(s)), + enif_make_int(env, pct)); } static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -1482,7 +1658,7 @@ static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv *env, int argc, const ER static ERL_NIF_TERM nif_device_foreground(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIApplicationState st = UIApplicationStateBackground; dispatch_sync(dispatch_get_main_queue(), ^{ - st = [UIApplication sharedApplication].applicationState; + st = [UIApplication sharedApplication].applicationState; }); return enif_make_atom(env, st == UIApplicationStateActive ? "true" : "false"); } @@ -1503,30 +1679,28 @@ static ERL_NIF_TERM nif_device_model(ErlNifEnv *env, int argc, const ERL_NIF_TER // Returns {Top, Right, Bottom, Left} in logical points (not pixels). // Must read UIWindow.safeAreaInsets on the main thread. -static ERL_NIF_TERM nif_safe_area(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIEdgeInsets insets = UIEdgeInsetsZero; dispatch_sync(dispatch_get_main_queue(), ^{ - UIWindow* window = nil; - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene* ws = (UIWindowScene*)scene; - window = ws.windows.firstObject; - break; - } - } - if (window) insets = window.safeAreaInsets; + UIWindow *window = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + UIWindowScene *ws = (UIWindowScene *)scene; + window = ws.windows.firstObject; + break; + } + } + if (window) + insets = window.safeAreaInsets; }); - return enif_make_tuple4(env, - enif_make_double(env, insets.top), - enif_make_double(env, insets.right), - enif_make_double(env, insets.bottom), - enif_make_double(env, insets.left) - ); + return enif_make_tuple4( + env, enif_make_double(env, insets.top), enif_make_double(env, insets.right), + enif_make_double(env, insets.bottom), enif_make_double(env, insets.left)); } // ── NIF: log/1 ──────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char buf[4096] = {0}; ErlNifBinary bin; if (enif_inspect_binary(env, argv[0], &bin)) { @@ -1542,7 +1716,7 @@ static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) // ── NIF: log/2 ──────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char level[16] = {0}; char buf[4096] = {0}; enif_get_atom(env, argv[0], level, sizeof(level), ERL_NIF_LATIN1); @@ -1560,7 +1734,7 @@ static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[] // ── NIF: set_transition/1 ───────────────────────────────────────────────────── -static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); if (!enif_get_atom(env, argv[0], g_transition, sizeof(g_transition), ERL_NIF_LATIN1)) { enif_mutex_unlock(tap_mutex); @@ -1574,22 +1748,23 @@ static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_T // Accepts a JSON binary, parses it to a MobNode tree, and pushes it to the // SwiftUI view model. Runs on the BEAM thread — MobViewModel dispatches to main. -static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSData* data = [NSData dataWithBytes:bin.data length:bin.size]; - NSError* err = nil; + NSData *data = [NSData dataWithBytes:bin.data length:bin.size]; + NSError *err = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err]; if (err || ![json isKindOfClass:[NSDictionary class]]) { LOGE(@"set_root: JSON parse error: %@", err); return enif_make_atom(env, "error"); } - MobNode* node = mob_node_from_dict((NSDictionary*)json); - if (!node) return enif_make_atom(env, "error"); + MobNode *node = mob_node_from_dict((NSDictionary *)json); + if (!node) + return enif_make_atom(env, "error"); // Snapshot and reset the transition enif_mutex_lock(tap_mutex); @@ -1599,7 +1774,7 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar strncpy(g_transition, "none", sizeof(g_transition)); enif_mutex_unlock(tap_mutex); - NSString* transitionStr = [NSString stringWithUTF8String:transition]; + NSString *transitionStr = [NSString stringWithUTF8String:transition]; [[MobViewModel shared] setRoot:node transition:transitionStr]; return enif_make_atom(env, "ok"); @@ -1607,15 +1782,15 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // ── NIF: register_tap/1 ────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; +static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; ERL_NIF_TERM tag_term; if (enif_get_local_pid(env, argv[0], &pid)) { tag_term = enif_make_atom(env, "ok"); } else { int arity; - const ERL_NIF_TERM* elems; + const ERL_NIF_TERM *elems; if (!enif_get_tuple(env, argv[0], &arity, &elems) || arity != 2) return enif_make_badarg(env); if (!enif_get_local_pid(env, elems[0], &pid)) @@ -1629,9 +1804,9 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER return enif_make_badarg(env); } int handle = tap_handle_next++; - tap_handles[handle].pid = pid; + tap_handles[handle].pid = pid; tap_handles[handle].tag_env = enif_alloc_env(); - tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); + tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); enif_mutex_unlock(tap_mutex); return enif_make_int(env, handle); @@ -1639,7 +1814,7 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER // ── NIF: clear_taps/0 ───────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); for (int i = 0; i < tap_handle_next; i++) { if (tap_handles[i].tag_env) { @@ -1647,15 +1822,15 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM tap_handles[i].tag_env = NULL; } // Reset throttle state — slots get reused across renders. - tap_handles[i].throttle_ms = 0; - tap_handles[i].debounce_ms = 0; + tap_handles[i].throttle_ms = 0; + tap_handles[i].debounce_ms = 0; tap_handles[i].delta_threshold = 0; - tap_handles[i].leading = 1; - tap_handles[i].trailing = 1; - tap_handles[i].last_emit_ns = 0; - tap_handles[i].last_x = 0; - tap_handles[i].last_y = 0; - tap_handles[i].seq = 0; + tap_handles[i].leading = 1; + tap_handles[i].trailing = 1; + tap_handles[i].last_emit_ns = 0; + tap_handles[i].last_x = 0; + tap_handles[i].last_y = 0; + tap_handles[i].seq = 0; } tap_handle_next = 0; enif_mutex_unlock(tap_mutex); @@ -1665,31 +1840,32 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ── NIF: haptic/1 ───────────────────────────────────────────────────────────── // Triggers haptic feedback. Fire-and-forget; dispatched async to main thread. -static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_haptic(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char type[32] = {0}; enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - NSString* typeStr = [NSString stringWithUTF8String:type]; + NSString *typeStr = [NSString stringWithUTF8String:type]; dispatch_async(dispatch_get_main_queue(), ^{ - if ([typeStr isEqualToString:@"success"] || - [typeStr isEqualToString:@"error"] || - [typeStr isEqualToString:@"warning"]) { - UINotificationFeedbackGenerator* g = [[UINotificationFeedbackGenerator alloc] init]; - [g prepare]; - if ([typeStr isEqualToString:@"success"]) - [g notificationOccurred:UINotificationFeedbackTypeSuccess]; - else if ([typeStr isEqualToString:@"error"]) - [g notificationOccurred:UINotificationFeedbackTypeError]; - else - [g notificationOccurred:UINotificationFeedbackTypeWarning]; - } else { - UIImpactFeedbackStyle style = UIImpactFeedbackStyleMedium; - if ([typeStr isEqualToString:@"light"]) style = UIImpactFeedbackStyleLight; - if ([typeStr isEqualToString:@"heavy"]) style = UIImpactFeedbackStyleHeavy; - UIImpactFeedbackGenerator* g = [[UIImpactFeedbackGenerator alloc] initWithStyle:style]; - [g prepare]; - [g impactOccurred]; - } + if ([typeStr isEqualToString:@"success"] || [typeStr isEqualToString:@"error"] || + [typeStr isEqualToString:@"warning"]) { + UINotificationFeedbackGenerator *g = [[UINotificationFeedbackGenerator alloc] init]; + [g prepare]; + if ([typeStr isEqualToString:@"success"]) + [g notificationOccurred:UINotificationFeedbackTypeSuccess]; + else if ([typeStr isEqualToString:@"error"]) + [g notificationOccurred:UINotificationFeedbackTypeError]; + else + [g notificationOccurred:UINotificationFeedbackTypeWarning]; + } else { + UIImpactFeedbackStyle style = UIImpactFeedbackStyleMedium; + if ([typeStr isEqualToString:@"light"]) + style = UIImpactFeedbackStyleLight; + if ([typeStr isEqualToString:@"heavy"]) + style = UIImpactFeedbackStyleHeavy; + UIImpactFeedbackGenerator *g = [[UIImpactFeedbackGenerator alloc] initWithStyle:style]; + [g prepare]; + [g impactOccurred]; + } }); return enif_make_atom(env, "ok"); } @@ -1697,17 +1873,17 @@ static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv // ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── // Writes a UTF-8 binary to the system clipboard. Fire-and-forget. -static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* text = [[NSString alloc] initWithBytes:bin.data + NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [UIPasteboard generalPasteboard].string = text; + [UIPasteboard generalPasteboard].string = text; }); return enif_make_atom(env, "ok"); } @@ -1715,14 +1891,14 @@ static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TE // ── NIF: clipboard_get/0 ────────────────────────────────────────────────────── // Returns {:ok, Binary} or :empty. Synchronous (dispatch_sync to main thread). -static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - __block NSString* text = nil; +static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + __block NSString *text = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - text = [UIPasteboard generalPasteboard].string; + text = [UIPasteboard generalPasteboard].string; }); if (text) { - const char* utf8 = [text UTF8String]; + const char *utf8 = [text UTF8String]; ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -1737,20 +1913,21 @@ static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TE // Hands a URL to the OS to open in the user's default browser/app. // Fire-and-forget; returns :ok immediately. -static ERL_NIF_TERM nif_open_url(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_open_url(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* str = [[NSString alloc] initWithBytes:bin.data + NSString *str = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - NSURL* url = [NSURL URLWithString:str]; - if (!url) return enif_make_badarg(env); + NSURL *url = [NSURL URLWithString:str]; + if (!url) + return enif_make_badarg(env); dispatch_async(dispatch_get_main_queue(), ^{ - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; }); return enif_make_atom(env, "ok"); } @@ -1758,35 +1935,35 @@ static ERL_NIF_TERM nif_open_url(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // ── NIF: share_text/1 ───────────────────────────────────────────────────────── // Opens the iOS share sheet with plain text. Fire-and-forget. -static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_share_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* text = [[NSString alloc] initWithBytes:bin.data + NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - UIActivityViewController* vc = - [[UIActivityViewController alloc] initWithActivityItems:@[text] - applicationActivities:nil]; - UIViewController* root = nil; - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - root = ((UIWindowScene*)scene).windows.firstObject.rootViewController; - break; - } - } - if (root) { - if (vc.popoverPresentationController) { - vc.popoverPresentationController.sourceView = root.view; - CGRect r = root.view.bounds; - vc.popoverPresentationController.sourceRect = - CGRectMake(CGRectGetMidX(r), CGRectGetMidY(r), 0, 0); - } - [root presentViewController:vc animated:YES completion:nil]; - } + UIActivityViewController *vc = + [[UIActivityViewController alloc] initWithActivityItems:@[ text ] + applicationActivities:nil]; + UIViewController *root = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + root = ((UIWindowScene *)scene).windows.firstObject.rootViewController; + break; + } + } + if (root) { + if (vc.popoverPresentationController) { + vc.popoverPresentationController.sourceView = root.view; + CGRect r = root.view.bounds; + vc.popoverPresentationController.sourceRect = + CGRectMake(CGRectGetMidX(r), CGRectGetMidY(r), 0, 0); + } + [root presentViewController:vc animated:YES completion:nil]; + } }); return enif_make_atom(env, "ok"); } @@ -1798,29 +1975,30 @@ static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ── Shared helpers ───────────────────────────────────────────────────────── // Build and send {atom1, atom2} to a pid from any thread. -static void mob_send2(const ErlNifPid* pid, const char* a1, const char* a2) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,a1), enif_make_atom(e,a2)); - enif_send(NULL, (ErlNifPid*)pid, e, msg); +static void mob_send2(const ErlNifPid *pid, const char *a1, const char *a2) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, a1), enif_make_atom(e, a2)); + enif_send(NULL, (ErlNifPid *)pid, e, msg); enif_free_env(e); } // Build and send {atom1, atom2, atom3} to a pid from any thread. -static void mob_send3(const ErlNifPid* pid, const char* a1, const char* a2, const char* a3) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,a1), enif_make_atom(e,a2), enif_make_atom(e,a3)); - enif_send(NULL, (ErlNifPid*)pid, e, msg); +static void mob_send3(const ErlNifPid *pid, const char *a1, const char *a2, const char *a3) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, a1), enif_make_atom(e, a2), enif_make_atom(e, a3)); + enif_send(NULL, (ErlNifPid *)pid, e, msg); enif_free_env(e); } // Return the root view controller of the key window in the first active scene. -static UIViewController* mob_root_vc(void) { - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { +static UIViewController *mob_root_vc(void) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene* ws = (UIWindowScene*)scene; - UIWindow* w = ws.keyWindow ?: ws.windows.firstObject; - if (w.rootViewController) return w.rootViewController; + UIWindowScene *ws = (UIWindowScene *)scene; + UIWindow *w = ws.keyWindow ?: ws.windows.firstObject; + if (w.rootViewController) + return w.rootViewController; } } return nil; @@ -1829,45 +2007,50 @@ static void mob_send3(const ErlNifPid* pid, const char* a1, const char* a2, cons // ── Launch notification global ───────────────────────────────────────────── // Written by mob_set_launch_notification_json() (called from app delegate); // read and cleared by nif_take_launch_notification. -static char* g_launch_notification_json = NULL; -static ErlNifMutex* g_launch_notif_mutex = NULL; +static char *g_launch_notification_json = NULL; +static ErlNifMutex *g_launch_notif_mutex = NULL; @interface MobNotificationDelegate : NSObject -@property (nonatomic) ErlNifPid screenPid; +@property(nonatomic) ErlNifPid screenPid; @end -static MobNotificationDelegate* g_notif_delegate; +static MobNotificationDelegate *g_notif_delegate; // Called from AppDelegate didRegisterForRemoteNotificationsWithDeviceToken. // Sends {:push_token, :ios, token_hex_string} to the registered screen process. -void mob_send_push_token(const char* hex_token) { - if (!g_notif_delegate) return; +void mob_send_push_token(const char *hex_token) { + if (!g_notif_delegate) + return; ErlNifPid p = g_notif_delegate.screenPid; - ErlNifEnv* e = enif_alloc_env(); + ErlNifEnv *e = enif_alloc_env(); size_t len = strlen(hex_token); - ErlNifBinary tb; enif_alloc_binary(len, &tb); memcpy(tb.data, hex_token, len); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"push_token"), - enif_make_atom(e,"ios"), - enif_make_binary(e,&tb)); + ErlNifBinary tb; + enif_alloc_binary(len, &tb); + memcpy(tb.data, hex_token, len); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "push_token"), + enif_make_atom(e, "ios"), enif_make_binary(e, &tb)); enif_send(NULL, &p, e, msg); enif_free_env(e); } -void mob_set_launch_notification_json(const char* json) { - if (!g_launch_notif_mutex) return; +void mob_set_launch_notification_json(const char *json) { + if (!g_launch_notif_mutex) + return; enif_mutex_lock(g_launch_notif_mutex); free(g_launch_notification_json); g_launch_notification_json = json ? strdup(json) : NULL; enif_mutex_unlock(g_launch_notif_mutex); } -static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!g_launch_notif_mutex) return enif_make_atom(env, "none"); +static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + if (!g_launch_notif_mutex) + return enif_make_atom(env, "none"); enif_mutex_lock(g_launch_notif_mutex); - char* json = g_launch_notification_json; + char *json = g_launch_notification_json; g_launch_notification_json = NULL; enif_mutex_unlock(g_launch_notif_mutex); - if (!json) return enif_make_atom(env, "none"); + if (!json) + return enif_make_atom(env, "none"); ErlNifBinary bin; size_t len = strlen(json); enif_alloc_binary(len, &bin); @@ -1878,7 +2061,7 @@ static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const // ── Permission request ──────────────────────────────────────────────────── -static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char cap[32]; if (!enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1)) return enif_make_badarg(env); @@ -1886,30 +2069,35 @@ static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_N enif_self(env, &pid); if (strcmp(cap, "camera") == 0 || strcmp(cap, "microphone") == 0) { - AVMediaType mtype = strcmp(cap, "camera") == 0 - ? AVMediaTypeVideo : AVMediaTypeAudio; - NSString* capStr = [NSString stringWithUTF8String:cap]; - [AVCaptureDevice requestAccessForMediaType:mtype completionHandler:^(BOOL granted) { - mob_send3(&pid, "permission", capStr.UTF8String, granted ? "granted" : "denied"); - }]; + AVMediaType mtype = strcmp(cap, "camera") == 0 ? AVMediaTypeVideo : AVMediaTypeAudio; + NSString *capStr = [NSString stringWithUTF8String:cap]; + [AVCaptureDevice requestAccessForMediaType:mtype + completionHandler:^(BOOL granted) { + mob_send3(&pid, "permission", capStr.UTF8String, + granted ? "granted" : "denied"); + }]; } else if (strcmp(cap, "photo_library") == 0) { - [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite - handler:^(PHAuthorizationStatus status) { - BOOL ok = (status == PHAuthorizationStatusAuthorized || - status == PHAuthorizationStatusLimited); - mob_send3(&pid, "permission", "photo_library", ok ? "granted" : "denied"); - }]; + [PHPhotoLibrary + requestAuthorizationForAccessLevel:PHAccessLevelReadWrite + handler:^(PHAuthorizationStatus status) { + BOOL ok = (status == PHAuthorizationStatusAuthorized || + status == PHAuthorizationStatusLimited); + mob_send3(&pid, "permission", "photo_library", + ok ? "granted" : "denied"); + }]; } else if (strcmp(cap, "location") == 0) { // Location permission is requested via CLLocationManager when get_once/start are called. // Here we just signal granted for iOS (the actual dialog shows at location call time). mob_send3(&pid, "permission", "location", "granted"); } else if (strcmp(cap, "notifications") == 0) { - UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; - [center requestAuthorizationWithOptions: - UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge - completionHandler:^(BOOL granted, NSError* err) { - mob_send3(&pid, "permission", "notifications", granted ? "granted" : "denied"); - }]; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center + requestAuthorizationWithOptions:UNAuthorizationOptionAlert | + UNAuthorizationOptionSound | UNAuthorizationOptionBadge + completionHandler:^(BOOL granted, NSError *err) { + mob_send3(&pid, "permission", "notifications", + granted ? "granted" : "denied"); + }]; } else { return enif_make_badarg(env); } @@ -1918,26 +2106,30 @@ static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_N // ── Biometric authentication ────────────────────────────────────────────── -static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* reason = [[NSString alloc] initWithBytes:bin.data length:bin.size + NSString *reason = [[NSString alloc] initWithBytes:bin.data + length:bin.size encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); + ErlNifPid pid; + enif_self(env, &pid); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - LAContext* ctx = [[LAContext alloc] init]; - NSError* err = nil; - if ([ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&err]) { - [ctx evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics - localizedReason:reason reply:^(BOOL ok, NSError* e) { - mob_send2(&pid, "biometric", ok ? "success" : "failure"); - }]; - } else { - mob_send2(&pid, "biometric", "not_available"); - } + LAContext *ctx = [[LAContext alloc] init]; + NSError *err = nil; + if ([ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&err]) { + [ctx evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics + localizedReason:reason + reply:^(BOOL ok, NSError *e) { + mob_send2(&pid, "biometric", ok ? "success" : "failure"); + }]; + } else { + mob_send2(&pid, "biometric", "not_available"); + } }); return enif_make_atom(env, "ok"); } @@ -1945,143 +2137,153 @@ static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv* env, int argc, const E // ── Location ────────────────────────────────────────────────────────────── @interface MobLocationDelegate : NSObject -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) BOOL oneShot; +@property(nonatomic) ErlNifPid pid; +@property(nonatomic) BOOL oneShot; @end -static MobLocationDelegate* g_location_delegate = nil; -static CLLocationManager* g_location_manager = nil; +static MobLocationDelegate *g_location_delegate = nil; +static CLLocationManager *g_location_manager = nil; @implementation MobLocationDelegate -- (void)locationManager:(CLLocationManager*)mgr didUpdateLocations:(NSArray*)locs { - CLLocation* loc = locs.lastObject; - if (!loc) return; +- (void)locationManager:(CLLocationManager *)mgr didUpdateLocations:(NSArray *)locs { + CLLocation *loc = locs.lastObject; + if (!loc) + return; ErlNifPid p = self.pid; double lat = loc.coordinate.latitude; double lon = loc.coordinate.longitude; double acc = loc.horizontalAccuracy; double alt = loc.altitude; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(e,"lat"), enif_make_atom(e,"lon"), - enif_make_atom(e,"accuracy"), enif_make_atom(e,"altitude") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(e,lat), enif_make_double(e,lon), - enif_make_double(e,acc), enif_make_double(e,alt) - }; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 4, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"location"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM keys[4] = {enif_make_atom(e, "lat"), enif_make_atom(e, "lon"), + enif_make_atom(e, "accuracy"), enif_make_atom(e, "altitude")}; + ERL_NIF_TERM vals[4] = {enif_make_double(e, lat), enif_make_double(e, lon), + enif_make_double(e, acc), enif_make_double(e, alt)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 4, &map); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "location"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); - if (self.oneShot) [mgr stopUpdatingLocation]; + if (self.oneShot) + [mgr stopUpdatingLocation]; } -- (void)locationManager:(CLLocationManager*)mgr didFailWithError:(NSError*)err { +- (void)locationManager:(CLLocationManager *)mgr didFailWithError:(NSError *)err { ErlNifPid p = self.pid; - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"location"), enif_make_atom(e,"error"), - enif_make_atom(e,"unavailable")); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "location"), enif_make_atom(e, "error"), + enif_make_atom(e, "unavailable")); enif_send(NULL, &p, e, msg); enif_free_env(e); } @end -static void setup_location_manager(ErlNifPid pid, BOOL oneShot, NSString* accuracy) { +static void setup_location_manager(ErlNifPid pid, BOOL oneShot, NSString *accuracy) { dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_location_manager) { - g_location_manager = [[CLLocationManager alloc] init]; - } - g_location_delegate = [[MobLocationDelegate alloc] init]; - g_location_delegate.pid = pid; - g_location_delegate.oneShot = oneShot; - g_location_manager.delegate = g_location_delegate; - if ([accuracy isEqualToString:@"high"]) { - g_location_manager.desiredAccuracy = kCLLocationAccuracyBest; - } else if ([accuracy isEqualToString:@"low"]) { - g_location_manager.desiredAccuracy = kCLLocationAccuracyKilometer; - } else { - g_location_manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; - } - [g_location_manager requestWhenInUseAuthorization]; - [g_location_manager startUpdatingLocation]; + if (!g_location_manager) { + g_location_manager = [[CLLocationManager alloc] init]; + } + g_location_delegate = [[MobLocationDelegate alloc] init]; + g_location_delegate.pid = pid; + g_location_delegate.oneShot = oneShot; + g_location_manager.delegate = g_location_delegate; + if ([accuracy isEqualToString:@"high"]) { + g_location_manager.desiredAccuracy = kCLLocationAccuracyBest; + } else if ([accuracy isEqualToString:@"low"]) { + g_location_manager.desiredAccuracy = kCLLocationAccuracyKilometer; + } else { + g_location_manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + } + [g_location_manager requestWhenInUseAuthorization]; + [g_location_manager startUpdatingLocation]; }); } -static ERL_NIF_TERM nif_location_get_once(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_location_get_once(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); setup_location_manager(pid, YES, @"balanced"); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_location_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_location_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char acc[16] = "balanced"; enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); + ErlNifPid pid; + enif_self(env, &pid); setup_location_manager(pid, NO, [NSString stringWithUTF8String:acc]); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_location_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_location_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_location_manager stopUpdatingLocation]; + [g_location_manager stopUpdatingLocation]; }); return enif_make_atom(env, "ok"); } // ── Camera capture ──────────────────────────────────────────────────────── -@interface MobCameraDelegate : NSObject -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) BOOL isVideo; +@interface MobCameraDelegate + : NSObject +@property(nonatomic) ErlNifPid pid; +@property(nonatomic) BOOL isVideo; @end -static MobCameraDelegate* g_camera_delegate = nil; +static MobCameraDelegate *g_camera_delegate = nil; @implementation MobCameraDelegate -- (void)imagePickerController:(UIImagePickerController*)picker - didFinishPickingMediaWithInfo:(NSDictionary*)info { +- (void)imagePickerController:(UIImagePickerController *)picker + didFinishPickingMediaWithInfo:(NSDictionary *)info { [picker dismissViewControllerAnimated:YES completion:nil]; ErlNifPid p = self.pid; BOOL isVid = self.isVideo; g_camera_delegate = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (!isVid) { - UIImage* img = info[UIImagePickerControllerOriginalImage]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_photo_%@.jpg", [NSUUID UUID].UUIDString]]; - [UIImageJPEGRepresentation(img, 0.9) writeToFile:tmp atomically:YES]; - const char* path = tmp.UTF8String; - ErlNifBinary pbin; enif_alloc_binary(strlen(path), &pbin); - memcpy(pbin.data, path, strlen(path)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"path"),enif_make_atom(e,"width"),enif_make_atom(e,"height")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&pbin), - enif_make_int(e,(int)img.size.width), enif_make_int(e,(int)img.size.height)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - msg = enif_make_tuple3(e, enif_make_atom(e,"camera"), enif_make_atom(e,"photo"), map); - } else { - NSURL* url = info[UIImagePickerControllerMediaURL]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_video_%@.mp4", [NSUUID UUID].UUIDString]]; - if (url) [[NSFileManager defaultManager] copyItemAtPath:url.path toPath:tmp error:nil]; - const char* path = tmp.UTF8String; - ErlNifBinary pbin; enif_alloc_binary(strlen(path), &pbin); - memcpy(pbin.data, path, strlen(path)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"path"), enif_make_atom(e,"duration")}; - ERL_NIF_TERM vals[2] = {enif_make_binary(e,&pbin), enif_make_double(e,0.0)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - msg = enif_make_tuple3(e, enif_make_atom(e,"camera"), enif_make_atom(e,"video"), map); - } - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg; + if (!isVid) { + UIImage *img = info[UIImagePickerControllerOriginalImage]; + NSString *tmp = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"mob_photo_%@.jpg", + [NSUUID UUID].UUIDString]]; + [UIImageJPEGRepresentation(img, 0.9) writeToFile:tmp atomically:YES]; + const char *path = tmp.UTF8String; + ErlNifBinary pbin; + enif_alloc_binary(strlen(path), &pbin); + memcpy(pbin.data, path, strlen(path)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "path"), enif_make_atom(e, "width"), + enif_make_atom(e, "height")}; + ERL_NIF_TERM vals[3] = {enif_make_binary(e, &pbin), enif_make_int(e, (int)img.size.width), + enif_make_int(e, (int)img.size.height)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + msg = enif_make_tuple3(e, enif_make_atom(e, "camera"), enif_make_atom(e, "photo"), map); + } else { + NSURL *url = info[UIImagePickerControllerMediaURL]; + NSString *tmp = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"mob_video_%@.mp4", + [NSUUID UUID].UUIDString]]; + if (url) + [[NSFileManager defaultManager] copyItemAtPath:url.path toPath:tmp error:nil]; + const char *path = tmp.UTF8String; + ErlNifBinary pbin; + enif_alloc_binary(strlen(path), &pbin); + memcpy(pbin.data, path, strlen(path)); + ERL_NIF_TERM keys[2] = {enif_make_atom(e, "path"), enif_make_atom(e, "duration")}; + ERL_NIF_TERM vals[2] = {enif_make_binary(e, &pbin), enif_make_double(e, 0.0)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 2, &map); + msg = enif_make_tuple3(e, enif_make_atom(e, "camera"), enif_make_atom(e, "video"), map); + } + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } -- (void)imagePickerControllerDidCancel:(UIImagePickerController*)picker { +- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { [picker dismissViewControllerAnimated:YES completion:nil]; mob_send2(&_pid, "camera", "cancelled"); g_camera_delegate = nil; @@ -2089,36 +2291,38 @@ - (void)imagePickerControllerDidCancel:(UIImagePickerController*)picker { @end static void present_image_picker(ErlNifPid pid, UIImagePickerControllerSourceType src, - UIImagePickerControllerCameraCaptureMode mode) { + UIImagePickerControllerCameraCaptureMode mode) { dispatch_async(dispatch_get_main_queue(), ^{ - if (![UIImagePickerController isSourceTypeAvailable:src]) { - mob_send2(&pid, "camera", "not_available"); - return; - } - UIImagePickerController* picker = [[UIImagePickerController alloc] init]; - picker.sourceType = src; - picker.cameraCaptureMode = mode; - if (mode == UIImagePickerControllerCameraCaptureModeVideo) { - picker.mediaTypes = @[UTTypeMovie.identifier]; - } - g_camera_delegate = [[MobCameraDelegate alloc] init]; - g_camera_delegate.pid = pid; - g_camera_delegate.isVideo = (mode == UIImagePickerControllerCameraCaptureModeVideo); - picker.delegate = g_camera_delegate; - - [mob_root_vc() presentViewController:picker animated:YES completion:nil]; + if (![UIImagePickerController isSourceTypeAvailable:src]) { + mob_send2(&pid, "camera", "not_available"); + return; + } + UIImagePickerController *picker = [[UIImagePickerController alloc] init]; + picker.sourceType = src; + picker.cameraCaptureMode = mode; + if (mode == UIImagePickerControllerCameraCaptureModeVideo) { + picker.mediaTypes = @[ UTTypeMovie.identifier ]; + } + g_camera_delegate = [[MobCameraDelegate alloc] init]; + g_camera_delegate.pid = pid; + g_camera_delegate.isVideo = (mode == UIImagePickerControllerCameraCaptureModeVideo); + picker.delegate = g_camera_delegate; + + [mob_root_vc() presentViewController:picker animated:YES completion:nil]; }); } -static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); present_image_picker(pid, UIImagePickerControllerSourceTypeCamera, UIImagePickerControllerCameraCaptureModePhoto); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); int max_sec = 60; enif_get_int(env, argv[0], &max_sec); present_image_picker(pid, UIImagePickerControllerSourceTypeCamera, @@ -2128,55 +2332,67 @@ static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv* env, int argc, const ERL // ── Camera preview ──────────────────────────────────────────────────────── -AVCaptureSession* g_preview_session = nil; +AVCaptureSession *g_preview_session = nil; -static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; - NSString* facing = @"back"; - if (enif_inspect_binary(env, argv[0], &bin) || enif_inspect_iolist_as_binary(env, argv[0], &bin)) { - NSString* json = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - NSDictionary* opts = [NSJSONSerialization JSONObjectWithData:[json dataUsingEncoding:NSUTF8StringEncoding] - options:0 error:nil]; - if ([opts[@"facing"] isEqualToString:@"front"]) facing = @"front"; + NSString *facing = @"back"; + if (enif_inspect_binary(env, argv[0], &bin) || + enif_inspect_iolist_as_binary(env, argv[0], &bin)) { + NSString *json = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + NSDictionary *opts = + [NSJSONSerialization JSONObjectWithData:[json dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + if ([opts[@"facing"] isEqualToString:@"front"]) + facing = @"front"; } // Session setup and startRunning must run on a background queue (Apple requirement). // After the session is running, update the shared global and notify the preview view // on the main queue so SwiftUI can safely read g_preview_session. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - AVCaptureDevicePosition position = [facing isEqualToString:@"front"] - ? AVCaptureDevicePositionFront - : AVCaptureDevicePositionBack; - AVCaptureDevice* device = [AVCaptureDevice defaultDeviceWithDeviceType:AVCaptureDeviceTypeBuiltInWideAngleCamera - mediaType:AVMediaTypeVideo - position:position]; - if (!device) return; - AVCaptureDeviceInput* input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil]; - if (!input) return; - AVCaptureSession* session = [[AVCaptureSession alloc] init]; - session.sessionPreset = AVCaptureSessionPresetHigh; - if ([session canAddInput:input]) [session addInput:input]; - [session startRunning]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (g_preview_session) [g_preview_session stopRunning]; - g_preview_session = session; - [[NSNotificationCenter defaultCenter] - postNotificationName:@"MobCameraSessionChanged" object:nil]; - }); + AVCaptureDevicePosition position = [facing isEqualToString:@"front"] + ? AVCaptureDevicePositionFront + : AVCaptureDevicePositionBack; + AVCaptureDevice *device = + [AVCaptureDevice defaultDeviceWithDeviceType:AVCaptureDeviceTypeBuiltInWideAngleCamera + mediaType:AVMediaTypeVideo + position:position]; + if (!device) + return; + AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil]; + if (!input) + return; + AVCaptureSession *session = [[AVCaptureSession alloc] init]; + session.sessionPreset = AVCaptureSessionPresetHigh; + if ([session canAddInput:input]) + [session addInput:input]; + [session startRunning]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (g_preview_session) + [g_preview_session stopRunning]; + g_preview_session = session; + [[NSNotificationCenter defaultCenter] postNotificationName:@"MobCameraSessionChanged" + object:nil]; + }); }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - AVCaptureSession* old = g_preview_session; - g_preview_session = nil; - [[NSNotificationCenter defaultCenter] - postNotificationName:@"MobCameraSessionChanged" object:nil]; - // Stop the session off the main queue so we don't block the UI. - if (old) dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + AVCaptureSession *old = g_preview_session; + g_preview_session = nil; + [[NSNotificationCenter defaultCenter] postNotificationName:@"MobCameraSessionChanged" + object:nil]; + // Stop the session off the main queue so we don't block the UI. + if (old) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [old stopRunning]; - }); + }); }); return enif_make_atom(env, "ok"); } @@ -2184,14 +2400,15 @@ static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv* env, int argc, const ERL_ // ── Photo library picker ────────────────────────────────────────────────── @interface MobPhotosDelegate : NSObject -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) int maxItems; +@property(nonatomic) ErlNifPid pid; +@property(nonatomic) int maxItems; @end -static MobPhotosDelegate* g_photos_delegate = nil; +static MobPhotosDelegate *g_photos_delegate = nil; @implementation MobPhotosDelegate -- (void)picker:(PHPickerViewController*)picker didFinishPicking:(NSArray*)results { +- (void)picker:(PHPickerViewController *)picker + didFinishPicking:(NSArray *)results { [picker dismissViewControllerAnimated:YES completion:nil]; if (results.count == 0) { mob_send2(&_pid, "photos", "cancelled"); @@ -2201,60 +2418,75 @@ - (void)picker:(PHPickerViewController*)picker didFinishPicking:(NSArray -@property (nonatomic) ErlNifPid pid; +@property(nonatomic) ErlNifPid pid; @end -static MobFilesDelegate* g_files_delegate = nil; +static MobFilesDelegate *g_files_delegate = nil; @implementation MobFilesDelegate -- (void)documentPicker:(UIDocumentPickerViewController*)ctrl - didPickDocumentsAtURLs:(NSArray*)urls { - if (urls.count == 0) { mob_send2(&_pid, "files", "cancelled"); g_files_delegate = nil; return; } +- (void)documentPicker:(UIDocumentPickerViewController *)ctrl + didPickDocumentsAtURLs:(NSArray *)urls { + if (urls.count == 0) { + mob_send2(&_pid, "files", "cancelled"); + g_files_delegate = nil; + return; + } ErlNifPid p = self.pid; g_files_delegate = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM list = enif_make_list(e, 0); - for (NSURL* url in urls.reverseObjectEnumerator) { - [url startAccessingSecurityScopedResource]; - NSString* name = url.lastPathComponent; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; - [[NSFileManager defaultManager] copyItemAtURL:url toURL:[NSURL fileURLWithPath:tmp] error:nil]; - [url stopAccessingSecurityScopedResource]; - NSDictionary* attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:tmp error:nil]; - long long sz = [attrs[NSFileSize] longLongValue]; - const char* path = tmp.UTF8String; - const char* nm = name.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(path), &pb); memcpy(pb.data, path, strlen(path)); - ErlNifBinary nb; enif_alloc_binary(strlen(nm), &nb); memcpy(nb.data, nm, strlen(nm)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"path"),enif_make_atom(e,"name"),enif_make_atom(e,"size")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&pb),enif_make_binary(e,&nb),enif_make_int64(e,sz)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - list = enif_make_list_cell(e, map, list); - } - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"files"), enif_make_atom(e,"picked"), list); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM list = enif_make_list(e, 0); + for (NSURL *url in urls.reverseObjectEnumerator) { + [url startAccessingSecurityScopedResource]; + NSString *name = url.lastPathComponent; + NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; + [[NSFileManager defaultManager] copyItemAtURL:url + toURL:[NSURL fileURLWithPath:tmp] + error:nil]; + [url stopAccessingSecurityScopedResource]; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:tmp + error:nil]; + long long sz = [attrs[NSFileSize] longLongValue]; + const char *path = tmp.UTF8String; + const char *nm = name.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(path), &pb); + memcpy(pb.data, path, strlen(path)); + ErlNifBinary nb; + enif_alloc_binary(strlen(nm), &nb); + memcpy(nb.data, nm, strlen(nm)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "path"), enif_make_atom(e, "name"), + enif_make_atom(e, "size")}; + ERL_NIF_TERM vals[3] = {enif_make_binary(e, &pb), enif_make_binary(e, &nb), + enif_make_int64(e, sz)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + list = enif_make_list_cell(e, map, list); + } + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "files"), enif_make_atom(e, "picked"), list); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } -- (void)documentPickerWasCancelled:(UIDocumentPickerViewController*)ctrl { +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)ctrl { mob_send2(&_pid, "files", "cancelled"); g_files_delegate = nil; } @end -static ERL_NIF_TERM nif_files_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_files_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); dispatch_async(dispatch_get_main_queue(), ^{ - UIDocumentPickerViewController* vc = - [[UIDocumentPickerViewController alloc] - initForOpeningContentTypes:@[UTTypeData] asCopy:YES]; - vc.allowsMultipleSelection = YES; - g_files_delegate = [[MobFilesDelegate alloc] init]; - g_files_delegate.pid = pid; - vc.delegate = g_files_delegate; - [mob_root_vc() presentViewController:vc animated:YES completion:nil]; + UIDocumentPickerViewController *vc = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[ UTTypeData ] + asCopy:YES]; + vc.allowsMultipleSelection = YES; + g_files_delegate = [[MobFilesDelegate alloc] init]; + g_files_delegate.pid = pid; + vc.delegate = g_files_delegate; + [mob_root_vc() presentViewController:vc animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } // ── Audio recording ─────────────────────────────────────────────────────── -static AVAudioRecorder* g_audio_recorder = nil; -static ErlNifPid g_audio_pid; -static NSString* g_audio_path = nil; -static NSDate* g_audio_start = nil; +static AVAudioRecorder *g_audio_recorder = nil; +static ErlNifPid g_audio_pid; +static NSString *g_audio_path = nil; +static NSDate *g_audio_start = nil; -static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); g_audio_pid = pid; dispatch_async(dispatch_get_main_queue(), ^{ - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:nil]; - [[AVAudioSession sharedInstance] setActive:YES error:nil]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_audio_%@.m4a", [NSUUID UUID].UUIDString]]; - g_audio_path = tmp; - g_audio_start = [NSDate date]; - NSURL* url = [NSURL fileURLWithPath:tmp]; - NSDictionary* settings = @{ - AVFormatIDKey: @(kAudioFormatMPEG4AAC), - AVSampleRateKey: @44100, - AVNumberOfChannelsKey: @1, - AVEncoderAudioQualityKey: @(AVAudioQualityMedium) - }; - g_audio_recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:nil]; - [g_audio_recorder record]; + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + NSString *tmp = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"mob_audio_%@.m4a", + [NSUUID UUID].UUIDString]]; + g_audio_path = tmp; + g_audio_start = [NSDate date]; + NSURL *url = [NSURL fileURLWithPath:tmp]; + NSDictionary *settings = @{ + AVFormatIDKey : @(kAudioFormatMPEG4AAC), + AVSampleRateKey : @44100, + AVNumberOfChannelsKey : @1, + AVEncoderAudioQualityKey : @(AVAudioQualityMedium) + }; + g_audio_recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:nil]; + [g_audio_recorder record]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_audio_recorder) return; - NSTimeInterval dur = -[g_audio_start timeIntervalSinceNow]; - [g_audio_recorder stop]; - [[AVAudioSession sharedInstance] setActive:NO error:nil]; - NSString* path = g_audio_path; - g_audio_recorder = nil; - ErlNifPid p = g_audio_pid; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); memcpy(pb.data, cpath, strlen(cpath)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"path"), enif_make_atom(e,"duration")}; - ERL_NIF_TERM vals[2] = {enif_make_binary(e,&pb), enif_make_double(e,dur)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e,"audio"), enif_make_atom(e,"recorded"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }); + if (!g_audio_recorder) + return; + NSTimeInterval dur = -[g_audio_start timeIntervalSinceNow]; + [g_audio_recorder stop]; + [[AVAudioSession sharedInstance] setActive:NO error:nil]; + NSString *path = g_audio_path; + g_audio_recorder = nil; + ErlNifPid p = g_audio_pid; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + ERL_NIF_TERM keys[2] = {enif_make_atom(e, "path"), enif_make_atom(e, "duration")}; + ERL_NIF_TERM vals[2] = {enif_make_binary(e, &pb), enif_make_double(e, dur)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 2, &map); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "audio"), enif_make_atom(e, "recorded"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); + }); }); return enif_make_atom(env, "ok"); } @@ -2379,220 +2633,263 @@ static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL @interface MobAudioPlayerDelegate : NSObject @end -static AVAudioPlayer* g_audio_player = nil; -static AVPlayer* g_av_player = nil; -static id g_av_observer = nil; -static ErlNifPid g_playback_pid; -static NSString* g_playback_path = nil; -static MobAudioPlayerDelegate* g_player_delegate = nil; +static AVAudioPlayer *g_audio_player = nil; +static AVPlayer *g_av_player = nil; +static id g_av_observer = nil; +static ErlNifPid g_playback_pid; +static NSString *g_playback_path = nil; +static MobAudioPlayerDelegate *g_player_delegate = nil; @implementation MobAudioPlayerDelegate -- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer*)player successfully:(BOOL)flag { - NSString* path = g_playback_path; +- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { + NSString *path = g_playback_path; ErlNifPid p = g_playback_pid; g_audio_player = nil; g_playback_path = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); memcpy(pb.data, cpath, strlen(cpath)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), - enif_make_atom(e, "playback_finished"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_finished"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } -- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer*)player error:(NSError*)error { +- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error { ErlNifPid p = g_playback_pid; - NSString* reason = error ? error.localizedDescription : @"decode_error"; + NSString *reason = error ? error.localizedDescription : @"decode_error"; g_audio_player = nil; g_playback_path = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cr = reason.UTF8String; - ErlNifBinary rb; enif_alloc_binary(strlen(cr), &rb); memcpy(rb.data, cr, strlen(cr)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), - enif_make_atom(e, "playback_error"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + const char *cr = reason.UTF8String; + ErlNifBinary rb; + enif_alloc_binary(strlen(cr), &rb); + memcpy(rb.data, cr, strlen(cr)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "audio"), enif_make_atom(e, "playback_error"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } @end -static ERL_NIF_TERM nif_audio_play(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_play(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary path_bin, opts_bin; if (!enif_inspect_binary(env, argv[0], &path_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &opts_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) + return enif_make_badarg(env); - NSString* path = [[NSString alloc] initWithBytes:path_bin.data length:path_bin.size encoding:NSUTF8StringEncoding]; - NSString* opts = [[NSString alloc] initWithBytes:opts_bin.data length:opts_bin.size encoding:NSUTF8StringEncoding]; + NSString *path = [[NSString alloc] initWithBytes:path_bin.data + length:path_bin.size + encoding:NSUTF8StringEncoding]; + NSString *opts = [[NSString alloc] initWithBytes:opts_bin.data + length:opts_bin.size + encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); - g_playback_pid = pid; + ErlNifPid pid; + enif_self(env, &pid); + g_playback_pid = pid; g_playback_path = path; dispatch_async(dispatch_get_main_queue(), ^{ - NSDictionary* o = [NSJSONSerialization - JSONObjectWithData:[opts dataUsingEncoding:NSUTF8StringEncoding] - options:0 error:nil]; - BOOL loop = [o[@"loop"] boolValue]; - double volume = o[@"volume"] ? [o[@"volume"] doubleValue] : 1.0; - - // Stop any in-flight players. - [g_audio_player stop]; - g_audio_player = nil; - if (g_av_observer) { [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; g_av_observer = nil; } - [g_av_player pause]; - g_av_player = nil; - - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; - [[AVAudioSession sharedInstance] setActive:YES error:nil]; - - BOOL isRemote = [path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]; - if (isRemote) { - // Remote URL — use AVPlayer (AVAudioPlayer cannot stream HTTP). - NSURL* url = [NSURL URLWithString:path]; - AVPlayerItem* item = [AVPlayerItem playerItemWithURL:url]; - AVPlayer* player = [AVPlayer playerWithPlayerItem:item]; - player.volume = (float)volume; - g_av_player = player; - - ErlNifPid p = g_playback_pid; - NSString* pPath = path; - g_av_observer = [[NSNotificationCenter defaultCenter] - addObserverForName:AVPlayerItemDidPlayToEndTimeNotification - object:item queue:nil - usingBlock:^(NSNotification* n) { - if (loop) { - [g_av_player seekToTime:kCMTimeZero]; - [g_av_player play]; - } else { - g_av_player = nil; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cp = pPath.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cp), &pb); memcpy(pb.data, cp, strlen(cp)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - enif_send(NULL, &p, e, enif_make_tuple3(e, - enif_make_atom(e, "audio"), enif_make_atom(e, "playback_finished"), map)); - enif_free_env(e); - }); - } - }]; - [player play]; - return; - } - - // Local file — use AVAudioPlayer. - NSURL* url = [NSURL fileURLWithPath:path]; - NSError* err = nil; - AVAudioPlayer* player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&err]; - if (!player || err) { - NSString* reason = err ? err.localizedDescription : @"open_failed"; - ErlNifPid p = g_playback_pid; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cr = reason.UTF8String; - ErlNifBinary rb; enif_alloc_binary(strlen(cr), &rb); memcpy(rb.data, cr, strlen(cr)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - enif_send(NULL, &p, e, enif_make_tuple3(e, - enif_make_atom(e, "audio"), enif_make_atom(e, "playback_error"), map)); - enif_free_env(e); - }); - return; - } - - if (!g_player_delegate) g_player_delegate = [[MobAudioPlayerDelegate alloc] init]; - player.delegate = g_player_delegate; - player.volume = (float)volume; - player.numberOfLoops = loop ? -1 : 0; - g_audio_player = player; - [player play]; + NSDictionary *o = + [NSJSONSerialization JSONObjectWithData:[opts dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + BOOL loop = [o[@"loop"] boolValue]; + double volume = o[@"volume"] ? [o[@"volume"] doubleValue] : 1.0; + + // Stop any in-flight players. + [g_audio_player stop]; + g_audio_player = nil; + if (g_av_observer) { + [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; + g_av_observer = nil; + } + [g_av_player pause]; + g_av_player = nil; + + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + + BOOL isRemote = [path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]; + if (isRemote) { + // Remote URL — use AVPlayer (AVAudioPlayer cannot stream HTTP). + NSURL *url = [NSURL URLWithString:path]; + AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; + AVPlayer *player = [AVPlayer playerWithPlayerItem:item]; + player.volume = (float)volume; + g_av_player = player; + + ErlNifPid p = g_playback_pid; + NSString *pPath = path; + g_av_observer = [[NSNotificationCenter defaultCenter] + addObserverForName:AVPlayerItemDidPlayToEndTimeNotification + object:item + queue:nil + usingBlock:^(NSNotification *n) { + if (loop) { + [g_av_player seekToTime:kCMTimeZero]; + [g_av_player play]; + } else { + g_av_player = nil; + dispatch_async( + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cp = pPath.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cp), &pb); + memcpy(pb.data, cp, strlen(cp)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + enif_send(NULL, &p, e, + enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_finished"), + map)); + enif_free_env(e); + }); + } + }]; + [player play]; + return; + } + + // Local file — use AVAudioPlayer. + NSURL *url = [NSURL fileURLWithPath:path]; + NSError *err = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&err]; + if (!player || err) { + NSString *reason = err ? err.localizedDescription : @"open_failed"; + ErlNifPid p = g_playback_pid; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cr = reason.UTF8String; + ErlNifBinary rb; + enif_alloc_binary(strlen(cr), &rb); + memcpy(rb.data, cr, strlen(cr)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + enif_send(NULL, &p, e, + enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_error"), map)); + enif_free_env(e); + }); + return; + } + + if (!g_player_delegate) + g_player_delegate = [[MobAudioPlayerDelegate alloc] init]; + player.delegate = g_player_delegate; + player.volume = (float)volume; + player.numberOfLoops = loop ? -1 : 0; + g_audio_player = player; + [player play]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_audio_player stop]; - g_audio_player = nil; - if (g_av_observer) { [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; g_av_observer = nil; } - [g_av_player pause]; - g_av_player = nil; - g_playback_path = nil; - [[AVAudioSession sharedInstance] setActive:NO error:nil]; + [g_audio_player stop]; + g_audio_player = nil; + if (g_av_observer) { + [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; + g_av_observer = nil; + } + [g_av_player pause]; + g_av_player = nil; + g_playback_path = nil; + [[AVAudioSession sharedInstance] setActive:NO error:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double vol = 1.0; enif_get_double(env, argv[0], &vol); dispatch_async(dispatch_get_main_queue(), ^{ - g_audio_player.volume = (float)vol; - g_av_player.volume = (float)vol; + g_audio_player.volume = (float)vol; + g_av_player.volume = (float)vol; }); return enif_make_atom(env, "ok"); } // ── Motion sensors ──────────────────────────────────────────────────────── -static CMMotionManager* g_motion_manager = nil; -static ErlNifPid g_motion_pid; +static CMMotionManager *g_motion_manager = nil; +static ErlNifPid g_motion_pid; -static ERL_NIF_TERM nif_motion_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); g_motion_pid = pid; int interval_ms = 100; // argv[0] is a list of sensor name binaries; argv[1] is interval_ms int enif_get_int(env, argv[1], &interval_ms); dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_motion_manager) g_motion_manager = [[CMMotionManager alloc] init]; - NSTimeInterval interval = interval_ms / 1000.0; - g_motion_manager.deviceMotionUpdateInterval = interval; - [g_motion_manager startDeviceMotionUpdatesToQueue:[NSOperationQueue new] - withHandler:^(CMDeviceMotion* motion, NSError* err) { - if (!motion) return; - ErlNifPid p = g_motion_pid; - double ax = motion.userAcceleration.x + motion.gravity.x; - double ay = motion.userAcceleration.y + motion.gravity.y; - double az = motion.userAcceleration.z + motion.gravity.z; - double gx = motion.rotationRate.x; - double gy = motion.rotationRate.y; - double gz = motion.rotationRate.z; - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, - enif_make_double(e,ax), enif_make_double(e,ay), enif_make_double(e,az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, - enif_make_double(e,gx), enif_make_double(e,gy), enif_make_double(e,gz)); - long long ts = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"accel"),enif_make_atom(e,"gyro"),enif_make_atom(e,"timestamp")}; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e,ts)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"motion"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }]; + if (!g_motion_manager) + g_motion_manager = [[CMMotionManager alloc] init]; + NSTimeInterval interval = interval_ms / 1000.0; + g_motion_manager.deviceMotionUpdateInterval = interval; + [g_motion_manager + startDeviceMotionUpdatesToQueue:[NSOperationQueue new] + withHandler:^(CMDeviceMotion *motion, NSError *err) { + if (!motion) + return; + ErlNifPid p = g_motion_pid; + double ax = motion.userAcceleration.x + motion.gravity.x; + double ay = motion.userAcceleration.y + motion.gravity.y; + double az = motion.userAcceleration.z + motion.gravity.z; + double gx = motion.rotationRate.x; + double gy = motion.rotationRate.y; + double gz = motion.rotationRate.z; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), + enif_make_double(e, ay), + enif_make_double(e, az)); + ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), + enif_make_double(e, gy), + enif_make_double(e, gz)); + long long ts = + (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), + enif_make_atom(e, "gyro"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + ERL_NIF_TERM msg = + enif_make_tuple2(e, enif_make_atom(e, "motion"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); + }]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_motion_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_motion_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_motion_manager stopDeviceMotionUpdates]; + [g_motion_manager stopDeviceMotionUpdates]; }); return enif_make_atom(env, "ok"); } @@ -2600,38 +2897,42 @@ static ERL_NIF_TERM nif_motion_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ── QR / barcode scanner ────────────────────────────────────────────────── @interface MobScannerVC : UIViewController -@property (nonatomic) ErlNifPid pid; -@property (nonatomic, strong) AVCaptureSession* session; -@property (nonatomic, strong) AVCaptureVideoPreviewLayer* preview; +@property(nonatomic) ErlNifPid pid; +@property(nonatomic, strong) AVCaptureSession *session; +@property(nonatomic, strong) AVCaptureVideoPreviewLayer *preview; @end -static MobScannerVC* g_scanner_vc = nil; +static MobScannerVC *g_scanner_vc = nil; @implementation MobScannerVC - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor blackColor]; - NSError* err = nil; - AVCaptureDevice* dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; - AVCaptureDeviceInput* inp = [AVCaptureDeviceInput deviceInputWithDevice:dev error:&err]; - if (!inp) { mob_send2(&_pid, "scan", "not_available"); [self dismissViewControllerAnimated:YES completion:nil]; return; } + NSError *err = nil; + AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + AVCaptureDeviceInput *inp = [AVCaptureDeviceInput deviceInputWithDevice:dev error:&err]; + if (!inp) { + mob_send2(&_pid, "scan", "not_available"); + [self dismissViewControllerAnimated:YES completion:nil]; + return; + } self.session = [[AVCaptureSession alloc] init]; [self.session addInput:inp]; - AVCaptureMetadataOutput* out = [[AVCaptureMetadataOutput alloc] init]; + AVCaptureMetadataOutput *out = [[AVCaptureMetadataOutput alloc] init]; [self.session addOutput:out]; [out setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()]; out.metadataObjectTypes = @[ - AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, - AVMetadataObjectTypeEAN8Code, AVMetadataObjectTypeCode128Code, - AVMetadataObjectTypeCode39Code, AVMetadataObjectTypeAztecCode, - AVMetadataObjectTypePDF417Code, AVMetadataObjectTypeDataMatrixCode + AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, AVMetadataObjectTypeEAN8Code, + AVMetadataObjectTypeCode128Code, AVMetadataObjectTypeCode39Code, + AVMetadataObjectTypeAztecCode, AVMetadataObjectTypePDF417Code, + AVMetadataObjectTypeDataMatrixCode ]; self.preview = [AVCaptureVideoPreviewLayer layerWithSession:self.session]; self.preview.videoGravity = AVLayerVideoGravityResizeAspectFill; self.preview.frame = self.view.bounds; [self.view.layer addSublayer:self.preview]; // Cancel button - UIButton* btn = [UIButton buttonWithType:UIButtonTypeSystem]; + UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem]; [btn setTitle:@"Cancel" forState:UIControlStateNormal]; [btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; btn.frame = CGRectMake(16, 60, 80, 44); @@ -2649,42 +2950,55 @@ - (void)cancel { [self dismissViewControllerAnimated:YES completion:nil]; g_scanner_vc = nil; } -- (void)captureOutput:(AVCaptureOutput*)out - didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject*>*)metas - fromConnection:(AVCaptureConnection*)conn { - AVMetadataMachineReadableCodeObject* code = metas.firstObject; - if (!code || !code.stringValue) return; +- (void)captureOutput:(AVCaptureOutput *)out + didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metas + fromConnection:(AVCaptureConnection *)conn { + AVMetadataMachineReadableCodeObject *code = metas.firstObject; + if (!code || !code.stringValue) + return; [self.session stopRunning]; - NSString* val = code.stringValue; - NSString* typ = @"qr"; - if ([code.type isEqualToString:AVMetadataObjectTypeEAN13Code]) typ = @"ean13"; - else if ([code.type isEqualToString:AVMetadataObjectTypeEAN8Code]) typ = @"ean8"; - else if ([code.type isEqualToString:AVMetadataObjectTypeCode128Code]) typ = @"code128"; - else if ([code.type isEqualToString:AVMetadataObjectTypeCode39Code]) typ = @"code39"; + NSString *val = code.stringValue; + NSString *typ = @"qr"; + if ([code.type isEqualToString:AVMetadataObjectTypeEAN13Code]) + typ = @"ean13"; + else if ([code.type isEqualToString:AVMetadataObjectTypeEAN8Code]) + typ = @"ean8"; + else if ([code.type isEqualToString:AVMetadataObjectTypeCode128Code]) + typ = @"code128"; + else if ([code.type isEqualToString:AVMetadataObjectTypeCode39Code]) + typ = @"code39"; ErlNifPid p = self.pid; g_scanner_vc = nil; - [self dismissViewControllerAnimated:YES completion:^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cval = val.UTF8String; - const char* ctyp = typ.UTF8String; - ErlNifBinary vb; enif_alloc_binary(strlen(cval), &vb); memcpy(vb.data, cval, strlen(cval)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"type"), enif_make_atom(e,"value")}; - ERL_NIF_TERM vals[2] = {enif_make_atom(e,ctyp), enif_make_binary(e,&vb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e,"scan"), enif_make_atom(e,"result"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }]; + [self dismissViewControllerAnimated:YES + completion:^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cval = val.UTF8String; + const char *ctyp = typ.UTF8String; + ErlNifBinary vb; + enif_alloc_binary(strlen(cval), &vb); + memcpy(vb.data, cval, strlen(cval)); + ERL_NIF_TERM keys[2] = {enif_make_atom(e, "type"), + enif_make_atom(e, "value")}; + ERL_NIF_TERM vals[2] = {enif_make_atom(e, ctyp), + enif_make_binary(e, &vb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 2, &map); + ERL_NIF_TERM msg = enif_make_tuple3( + e, enif_make_atom(e, "scan"), enif_make_atom(e, "result"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); + }]; } @end -static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); dispatch_async(dispatch_get_main_queue(), ^{ - g_scanner_vc = [[MobScannerVC alloc] init]; - g_scanner_vc.pid = pid; - g_scanner_vc.modalPresentationStyle = UIModalPresentationFullScreen; - [mob_root_vc() presentViewController:g_scanner_vc animated:YES completion:nil]; + g_scanner_vc = [[MobScannerVC alloc] init]; + g_scanner_vc.pid = pid; + g_scanner_vc.modalPresentationStyle = UIModalPresentationFullScreen; + [mob_root_vc() presentViewController:g_scanner_vc animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } @@ -2693,35 +3007,41 @@ static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TER @implementation MobNotificationDelegate // Foreground delivery -- (void)userNotificationCenter:(UNUserNotificationCenter*)center - willPresentNotification:(UNNotification*)notification - withCompletionHandler:(void(^)(UNNotificationPresentationOptions))handler { +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions))handler { handler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound); [self deliverNotification:notification.request.content - source:@"local" id:notification.request.identifier]; + source:@"local" + id:notification.request.identifier]; } // Tap on notification (foreground or background) -- (void)userNotificationCenter:(UNUserNotificationCenter*)center - didReceiveNotificationResponse:(UNNotificationResponse*)response - withCompletionHandler:(void(^)(void))handler { +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))handler { [self deliverNotification:response.notification.request.content - source:@"local" id:response.notification.request.identifier]; + source:@"local" + id:response.notification.request.identifier]; handler(); } -- (void)deliverNotification:(UNNotificationContent*)content source:(NSString*)src id:(NSString*)nid { +- (void)deliverNotification:(UNNotificationContent *)content + source:(NSString *)src + id:(NSString *)nid { ErlNifPid p = self.screenPid; - ErlNifEnv* e = enif_alloc_env(); + ErlNifEnv *e = enif_alloc_env(); // Build data map from userInfo ERL_NIF_TERM data_map = enif_make_new_map(e); - NSDictionary* ui = content.userInfo; - for (NSString* key in ui) { + NSDictionary *ui = content.userInfo; + for (NSString *key in ui) { id val = ui[key]; - const char* ck = key.UTF8String; + const char *ck = key.UTF8String; ERL_NIF_TERM kterm = enif_make_atom(e, ck); ERL_NIF_TERM vterm; if ([val isKindOfClass:[NSString class]]) { - const char* cv = [val UTF8String]; - ErlNifBinary b; enif_alloc_binary(strlen(cv), &b); memcpy(b.data, cv, strlen(cv)); + const char *cv = [val UTF8String]; + ErlNifBinary b; + enif_alloc_binary(strlen(cv), &b); + memcpy(b.data, cv, strlen(cv)); vterm = enif_make_binary(e, &b); } else if ([val isKindOfClass:[NSNumber class]]) { vterm = enif_make_int64(e, [val longLongValue]); @@ -2730,95 +3050,105 @@ - (void)deliverNotification:(UNNotificationContent*)content source:(NSString*)sr } enif_make_map_put(e, data_map, kterm, vterm, &data_map); } - const char* cid = nid.UTF8String; - const char* csrc = src.UTF8String; - ErlNifBinary ib; enif_alloc_binary(strlen(cid), &ib); memcpy(ib.data, cid, strlen(cid)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"id"),enif_make_atom(e,"source"),enif_make_atom(e,"data")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&ib),enif_make_atom(e,csrc),data_map}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"notification"), map); + const char *cid = nid.UTF8String; + const char *csrc = src.UTF8String; + ErlNifBinary ib; + enif_alloc_binary(strlen(cid), &ib); + memcpy(ib.data, cid, strlen(cid)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "id"), enif_make_atom(e, "source"), + enif_make_atom(e, "data")}; + ERL_NIF_TERM vals[3] = {enif_make_binary(e, &ib), enif_make_atom(e, csrc), data_map}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "notification"), map); enif_send(NULL, &p, e, msg); enif_free_env(e); } @end -static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - ErlNifPid pid; enif_self(env, &pid); + ErlNifPid pid; + enif_self(env, &pid); // Copy JSON to heap-allocated buffer for use in async block - char* json = (char*)malloc(bin.size + 1); + char *json = (char *)malloc(bin.size + 1); memcpy(json, bin.data, bin.size); json[bin.size] = 0; dispatch_async(dispatch_get_main_queue(), ^{ - // Set delegate once - if (!g_notif_delegate) { - g_notif_delegate = [[MobNotificationDelegate alloc] init]; - g_notif_delegate.screenPid = pid; - [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; - } - g_notif_delegate.screenPid = pid; - - NSData* data = [NSData dataWithBytes:json length:strlen(json)]; - free(json); - NSDictionary* opts = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (!opts) return; - - UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; - content.title = opts[@"title"] ?: @""; - content.body = opts[@"body"] ?: @""; - NSDictionary* dataMap = opts[@"data"]; - if ([dataMap isKindOfClass:[NSDictionary class]]) content.userInfo = dataMap; - content.sound = [UNNotificationSound defaultSound]; - - NSTimeInterval delay = [opts[@"trigger_at"] doubleValue] - [[NSDate date] timeIntervalSince1970]; - if (delay < 1) delay = 1; - UNTimeIntervalNotificationTrigger* trigger = - [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO]; - NSString* nid = opts[@"id"] ?: [[NSUUID UUID] UUIDString]; - UNNotificationRequest* req = [UNNotificationRequest requestWithIdentifier:nid - content:content - trigger:trigger]; - [[UNUserNotificationCenter currentNotificationCenter] - addNotificationRequest:req withCompletionHandler:nil]; + // Set delegate once + if (!g_notif_delegate) { + g_notif_delegate = [[MobNotificationDelegate alloc] init]; + g_notif_delegate.screenPid = pid; + [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; + } + g_notif_delegate.screenPid = pid; + + NSData *data = [NSData dataWithBytes:json length:strlen(json)]; + free(json); + NSDictionary *opts = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (!opts) + return; + + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = opts[@"title"] ?: @""; + content.body = opts[@"body"] ?: @""; + NSDictionary *dataMap = opts[@"data"]; + if ([dataMap isKindOfClass:[NSDictionary class]]) + content.userInfo = dataMap; + content.sound = [UNNotificationSound defaultSound]; + + NSTimeInterval delay = + [opts[@"trigger_at"] doubleValue] - [[NSDate date] timeIntervalSince1970]; + if (delay < 1) + delay = 1; + UNTimeIntervalNotificationTrigger *trigger = + [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO]; + NSString *nid = opts[@"id"] ?: [[NSUUID UUID] UUIDString]; + UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:nid + content:content + trigger:trigger]; + [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:req + withCompletionHandler:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* nid = [[NSString alloc] initWithBytes:bin.data length:bin.size + NSString *nid = [[NSString alloc] initWithBytes:bin.data + length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [[UNUserNotificationCenter currentNotificationCenter] - removePendingNotificationRequestsWithIdentifiers:@[nid]]; + [[UNUserNotificationCenter currentNotificationCenter] + removePendingNotificationRequestsWithIdentifiers:@[ nid ]]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_notif_delegate) { - g_notif_delegate = [[MobNotificationDelegate alloc] init]; - [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; - } - g_notif_delegate.screenPid = pid; - [[UIApplication sharedApplication] registerForRemoteNotifications]; - // Token is delivered via AppDelegate didRegisterForRemoteNotificationsWithDeviceToken. - // Add a call to mob_send_push_token(token) there — see README for setup. + if (!g_notif_delegate) { + g_notif_delegate = [[MobNotificationDelegate alloc] init]; + [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; + } + g_notif_delegate.screenPid = pid; + [[UIApplication sharedApplication] registerForRemoteNotifications]; + // Token is delivered via AppDelegate didRegisterForRemoteNotificationsWithDeviceToken. + // Add a call to mob_send_push_token(token) there — see README for setup. }); return enif_make_atom(env, "ok"); } - // ════════════════════════════════════════════════════════════════════════════ // TEST HARNESS — compiled out of release builds (MOB_RELEASE). // ════════════════════════════════════════════════════════════════════════════ @@ -2840,9 +3170,11 @@ static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL // ── Test harness helpers (a11y walk, nsstring_to_term, AX framework) ─────────── static ERL_NIF_TERM nsstring_to_term(ErlNifEnv *env, NSString *s) { - if (!s) return enif_make_atom(env, "nil"); + if (!s) + return enif_make_atom(env, "nil"); const char *utf8 = [s UTF8String]; - if (!utf8) return enif_make_atom(env, "nil"); + if (!utf8) + return enif_make_atom(env, "nil"); size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); @@ -2851,39 +3183,45 @@ static ERL_NIF_TERM nsstring_to_term(ErlNifEnv *env, NSString *s) { } static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { - if (!obj || depth > 30) return; + if (!obj || depth > 30) + return; // Collect leaf accessibility elements (visible, interactive, or labelled nodes) BOOL isElem = [obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]; if (isElem) { - NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; - NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] - ? [(id)obj accessibilityValue] : nil; + NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] + ? [(id)obj accessibilityLabel] + : nil; + NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] + ? [(id)obj accessibilityValue] + : nil; UIAccessibilityTraits traits = [obj respondsToSelector:@selector(accessibilityTraits)] - ? [(id)obj accessibilityTraits] : 0; - CGRect frame = [obj respondsToSelector:@selector(accessibilityFrame)] - ? [(id)obj accessibilityFrame] : CGRectZero; + ? [(id)obj accessibilityTraits] + : 0; + CGRect frame = [obj respondsToSelector:@selector(accessibilityFrame)] + ? [(id)obj accessibilityFrame] + : CGRectZero; const char *type_str = "element"; - if (traits & UIAccessibilityTraitButton) type_str = "button"; - else if (traits & UIAccessibilityTraitStaticText) type_str = "text"; - else if (traits & UIAccessibilityTraitImage) type_str = "image"; - else if (traits & UIAccessibilityTraitHeader) type_str = "header"; - else if (traits & UIAccessibilityTraitSearchField) type_str = "text_field"; - - ERL_NIF_TERM frame_tup = enif_make_tuple4(env, - enif_make_double(env, frame.origin.x), - enif_make_double(env, frame.origin.y), - enif_make_double(env, frame.size.width), - enif_make_double(env, frame.size.height)); - - ERL_NIF_TERM elem = enif_make_tuple4(env, - enif_make_atom(env, type_str), - nsstring_to_term(env, label), - nsstring_to_term(env, value), - frame_tup); + if (traits & UIAccessibilityTraitButton) + type_str = "button"; + else if (traits & UIAccessibilityTraitStaticText) + type_str = "text"; + else if (traits & UIAccessibilityTraitImage) + type_str = "image"; + else if (traits & UIAccessibilityTraitHeader) + type_str = "header"; + else if (traits & UIAccessibilityTraitSearchField) + type_str = "text_field"; + + ERL_NIF_TERM frame_tup = enif_make_tuple4( + env, enif_make_double(env, frame.origin.x), enif_make_double(env, frame.origin.y), + enif_make_double(env, frame.size.width), enif_make_double(env, frame.size.height)); + + ERL_NIF_TERM elem = + enif_make_tuple4(env, enif_make_atom(env, type_str), nsstring_to_term(env, label), + nsstring_to_term(env, value), frame_tup); *list = enif_make_list_cell(env, elem, *list); } @@ -2896,7 +3234,8 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { NSArray *elems = [(id)obj accessibilityElements]; if (elems.count > 0) { for (id child in elems) { - if (child && child != obj) walk_a11y(env, child, list, depth + 1); + if (child && child != obj) + walk_a11y(env, child, list, depth + 1); } walked = YES; } @@ -2907,7 +3246,8 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { if (count != NSNotFound && count > 0) { for (NSInteger i = 0; i < count; i++) { id child = [(id)obj accessibilityElementAtIndex:i]; - if (child && child != obj) walk_a11y(env, child, list, depth + 1); + if (child && child != obj) + walk_a11y(env, child, list, depth + 1); } walked = YES; } @@ -2922,64 +3262,82 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { // ── NIF: ui_debug/0 — diagnostic: dumps window/view/a11y structure to NSLog ── static void debug_walk(id obj, int depth) { - if (!obj || depth > 8) return; - NSString *indent = [@"" stringByPaddingToLength:depth*2 withString:@" " startingAtIndex:0]; + if (!obj || depth > 8) + return; + NSString *indent = [@"" stringByPaddingToLength:depth * 2 withString:@" " startingAtIndex:0]; NSString *cls = NSStringFromClass([obj class]); - NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] ? [obj accessibilityLabel] : @"-"; - NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] ? [obj accessibilityValue] : @"-"; - BOOL isElem = [obj respondsToSelector:@selector(isAccessibilityElement)] && [obj isAccessibilityElement]; - NSInteger a11yCount = [obj respondsToSelector:@selector(accessibilityElementCount)] ? [obj accessibilityElementCount] : -99; - NSArray *a11yArr = [obj respondsToSelector:@selector(accessibilityElements)] ? [obj accessibilityElements] : nil; - NSInteger subCount = [obj isKindOfClass:[UIView class]] ? [(UIView*)obj subviews].count : -1; - NSLog(@"[ui_debug]%@%@ isElem=%d a11yCount=%ld a11yArr=%ld subs=%ld label=%@ value=%@", - indent, cls, isElem, (long)a11yCount, (long)a11yArr.count, (long)subCount, label, value); + NSString *label = + [obj respondsToSelector:@selector(accessibilityLabel)] ? [obj accessibilityLabel] : @"-"; + NSString *value = + [obj respondsToSelector:@selector(accessibilityValue)] ? [obj accessibilityValue] : @"-"; + BOOL isElem = + [obj respondsToSelector:@selector(isAccessibilityElement)] && [obj isAccessibilityElement]; + NSInteger a11yCount = [obj respondsToSelector:@selector(accessibilityElementCount)] + ? [obj accessibilityElementCount] + : -99; + NSArray *a11yArr = [obj respondsToSelector:@selector(accessibilityElements)] + ? [obj accessibilityElements] + : nil; + NSInteger subCount = [obj isKindOfClass:[UIView class]] ? [(UIView *)obj subviews].count : -1; + NSLog(@"[ui_debug]%@%@ isElem=%d a11yCount=%ld a11yArr=%ld subs=%ld label=%@ value=%@", indent, + cls, isElem, (long)a11yCount, (long)a11yArr.count, (long)subCount, label, value); if ([obj respondsToSelector:@selector(accessibilityElementCount)]) { NSInteger cnt = [obj accessibilityElementCount]; if (cnt != NSNotFound && cnt > 0) { - for (NSInteger i = 0; i < cnt; i++) debug_walk([obj accessibilityElementAtIndex:i], depth+1); + for (NSInteger i = 0; i < cnt; i++) + debug_walk([obj accessibilityElementAtIndex:i], depth + 1); } } - for (id child in [obj respondsToSelector:@selector(accessibilityElements)] ? [obj accessibilityElements] : @[]) - debug_walk(child, depth+1); + for (id child in [obj respondsToSelector:@selector(accessibilityElements)] + ? [obj accessibilityElements] + : @[]) + debug_walk(child, depth + 1); if ([obj isKindOfClass:[UIView class]]) - for (UIView *sub in [(UIView*)obj subviews]) debug_walk(sub, depth+1); + for (UIView *sub in [(UIView *)obj subviews]) + debug_walk(sub, depth + 1); } // Walk macOS AXUIElement tree (works because the iOS Simulator IS a macOS process). // We load ApplicationServices from the Mac host path (not the simulator runtime root). typedef void *AXUIElementRef_t; -typedef int AXError_t; +typedef int AXError_t; typedef void *(*AXUIElementCreateApplicationFn)(pid_t pid); -typedef AXError_t (*AXUIElementCopyAttributeValueFn)(AXUIElementRef_t elem, void *attr, void **value); +typedef AXError_t (*AXUIElementCopyAttributeValueFn)(AXUIElementRef_t elem, void *attr, + void **value); typedef AXError_t (*AXUIElementCopyAttributeNamesFn)(AXUIElementRef_t elem, void **names); typedef Boolean (*AXIsProcessTrustedFn)(void); static void *g_AppSvc = NULL; -static AXUIElementCreateApplicationFn g_AXCreateApp = NULL; -static AXUIElementCopyAttributeValueFn g_AXCopyAttr = NULL; -static AXIsProcessTrustedFn g_AXIsTrusted = NULL; +static AXUIElementCreateApplicationFn g_AXCreateApp = NULL; +static AXUIElementCopyAttributeValueFn g_AXCopyAttr = NULL; +static AXIsProcessTrustedFn g_AXIsTrusted = NULL; static NSString *g_ax_load_error = nil; static void load_ax(void) { - if (g_AppSvc) return; + if (g_AppSvc) + return; // The iOS Simulator is a macOS process. Check if AX symbols are already available // in the process image (RTLD_DEFAULT searches all loaded libraries). if (dlsym) { void *fn = dlsym(RTLD_DEFAULT, "AXUIElementCreateApplication"); if (fn) { - g_AppSvc = RTLD_DEFAULT; // sentinel: symbols are available + g_AppSvc = RTLD_DEFAULT; // sentinel: symbols are available } else { const char *err = dlerror ? dlerror() : "no dlerror"; - g_ax_load_error = [NSString stringWithFormat:@"RTLD_DEFAULT AXUIElementCreateApplication: %s", err]; + g_ax_load_error = + [NSString stringWithFormat:@"RTLD_DEFAULT AXUIElementCreateApplication: %s", err]; } } - if (!g_AppSvc) return; - g_AXCreateApp = (AXUIElementCreateApplicationFn) dlsym(g_AppSvc, "AXUIElementCreateApplication"); - g_AXCopyAttr = (AXUIElementCopyAttributeValueFn)dlsym(g_AppSvc, "AXUIElementCopyAttributeValue"); - g_AXIsTrusted = (AXIsProcessTrustedFn) dlsym(g_AppSvc, "AXIsProcessTrusted"); + if (!g_AppSvc) + return; + g_AXCreateApp = (AXUIElementCreateApplicationFn)dlsym(g_AppSvc, "AXUIElementCreateApplication"); + g_AXCopyAttr = + (AXUIElementCopyAttributeValueFn)dlsym(g_AppSvc, "AXUIElementCopyAttributeValue"); + g_AXIsTrusted = (AXIsProcessTrustedFn)dlsym(g_AppSvc, "AXIsProcessTrusted"); } static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { - if (!elem || depth > 20) return; + if (!elem || depth > 20) + return; // role void *role = NULL; g_AXCopyAttr(elem, (void *)CFSTR("AXRole"), &role); @@ -2996,7 +3354,7 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // Only emit if we have a role (leaf or intermediate) if (role) { // CF types loaded via dlopen — bridge via CFStringRef intermediate (no ARC transfer) - NSString *roleStr = (__bridge NSString *)((CFStringRef)role); + NSString *roleStr = (__bridge NSString *)((CFStringRef)role); NSString *labelStr = label ? (__bridge NSString *)((CFStringRef)label) : @""; NSString *valueStr = value ? (__bridge NSString *)((CFStringRef)value) : @""; CGRect frame = CGRectZero; @@ -3004,23 +3362,23 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // AXFrame value is an AXValue (AXValueType kAXValueCGRectType == 3) typedef Boolean (*AXValueGetValueFn)(CFTypeRef axval, int type, void *out); AXValueGetValueFn axGetVal = (AXValueGetValueFn)dlsym(g_AppSvc, "AXValueGetValue"); - if (axGetVal) axGetVal((CFTypeRef)frameVal, 3, &frame); + if (axGetVal) + axGetVal((CFTypeRef)frameVal, 3, &frame); CFRelease((CFTypeRef)frameVal); } - ERL_NIF_TERM frame_tup = enif_make_tuple4(env, - enif_make_double(env, frame.origin.x), - enif_make_double(env, frame.origin.y), - enif_make_double(env, frame.size.width), - enif_make_double(env, frame.size.height)); - ERL_NIF_TERM elem_tup = enif_make_tuple4(env, - nsstring_to_term(env, roleStr), - nsstring_to_term(env, labelStr), - nsstring_to_term(env, valueStr), - frame_tup); + ERL_NIF_TERM frame_tup = enif_make_tuple4( + env, enif_make_double(env, frame.origin.x), enif_make_double(env, frame.origin.y), + enif_make_double(env, frame.size.width), enif_make_double(env, frame.size.height)); + ERL_NIF_TERM elem_tup = + enif_make_tuple4(env, nsstring_to_term(env, roleStr), nsstring_to_term(env, labelStr), + nsstring_to_term(env, valueStr), frame_tup); *list = enif_make_list_cell(env, elem_tup, *list); - if (role) CFRelease((CFTypeRef)role); - if (label) CFRelease((CFTypeRef)label); - if (value) CFRelease((CFTypeRef)value); + if (role) + CFRelease((CFTypeRef)role); + if (label) + CFRelease((CFTypeRef)label); + if (value) + CFRelease((CFTypeRef)value); } // recurse into children void *children = NULL; @@ -3035,8 +3393,6 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { } } - - // ── view-tree walker (no AX activation needed) ─────────────────────────────── // // Walks UIView.subviews directly instead of going through the accessibility @@ -3053,16 +3409,26 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // things AX wouldn't surface. static const char *classify_view_type(UIView *view) { - if ([view isKindOfClass:[UIButton class]]) return "button"; - if ([view isKindOfClass:[UISwitch class]]) return "switch"; - if ([view isKindOfClass:[UISlider class]]) return "slider"; - if ([view isKindOfClass:[UITextField class]]) return "text_field"; - if ([view isKindOfClass:[UITextView class]]) return "text_field"; - if ([view isKindOfClass:[UILabel class]]) return "text"; - if ([view isKindOfClass:[UIImageView class]]) return "image"; - if ([view isKindOfClass:[UIScrollView class]]) return "scroll"; - if ([view isKindOfClass:[UIPickerView class]]) return "picker"; - if ([view isKindOfClass:[UIWindow class]]) return "window"; + if ([view isKindOfClass:[UIButton class]]) + return "button"; + if ([view isKindOfClass:[UISwitch class]]) + return "switch"; + if ([view isKindOfClass:[UISlider class]]) + return "slider"; + if ([view isKindOfClass:[UITextField class]]) + return "text_field"; + if ([view isKindOfClass:[UITextView class]]) + return "text_field"; + if ([view isKindOfClass:[UILabel class]]) + return "text"; + if ([view isKindOfClass:[UIImageView class]]) + return "image"; + if ([view isKindOfClass:[UIScrollView class]]) + return "scroll"; + if ([view isKindOfClass:[UIPickerView class]]) + return "picker"; + if ([view isKindOfClass:[UIWindow class]]) + return "window"; return "view"; } @@ -3072,26 +3438,29 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { NSString *t = [btn titleForState:UIControlStateNormal]; return t.length ? t : btn.titleLabel.text; } - if ([view isKindOfClass:[UILabel class]]) return ((UILabel *)view).text; - if ([view isKindOfClass:[UITextField class]]) return ((UITextField *)view).text; - if ([view isKindOfClass:[UITextView class]]) return ((UITextView *)view).text; - if (view.accessibilityLabel.length) return view.accessibilityLabel; + if ([view isKindOfClass:[UILabel class]]) + return ((UILabel *)view).text; + if ([view isKindOfClass:[UITextField class]]) + return ((UITextField *)view).text; + if ([view isKindOfClass:[UITextView class]]) + return ((UITextView *)view).text; + if (view.accessibilityLabel.length) + return view.accessibilityLabel; return nil; } static ERL_NIF_TERM build_view_node(ErlNifEnv *env, UIView *view, int depth) { - if (!view || depth > 50) return enif_make_atom(env, "nil"); + if (!view || depth > 50) + return enif_make_atom(env, "nil"); CGRect win_frame = [view convertRect:view.bounds toView:nil]; - NSString *text = extract_view_text(view); - NSString *value = view.accessibilityValue; + NSString *text = extract_view_text(view); + NSString *value = view.accessibilityValue; const char *type_str = classify_view_type(view); - ERL_NIF_TERM frame = enif_make_tuple4(env, - enif_make_double(env, win_frame.origin.x), - enif_make_double(env, win_frame.origin.y), - enif_make_double(env, win_frame.size.width), - enif_make_double(env, win_frame.size.height)); + ERL_NIF_TERM frame = enif_make_tuple4( + env, enif_make_double(env, win_frame.origin.x), enif_make_double(env, win_frame.origin.y), + enif_make_double(env, win_frame.size.width), enif_make_double(env, win_frame.size.height)); NSArray *subs = view.subviews; ERL_NIF_TERM children = enif_make_list(env, 0); @@ -3100,20 +3469,11 @@ static ERL_NIF_TERM build_view_node(ErlNifEnv *env, UIView *view, int depth) { children = enif_make_list_cell(env, child, children); } - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "type"), - enif_make_atom(env, "label"), - enif_make_atom(env, "value"), - enif_make_atom(env, "frame"), - enif_make_atom(env, "children") - }; - ERL_NIF_TERM vals[5] = { - enif_make_atom(env, type_str), - nsstring_to_term(env, text), - nsstring_to_term(env, value), - frame, - children - }; + ERL_NIF_TERM keys[5] = {enif_make_atom(env, "type"), enif_make_atom(env, "label"), + enif_make_atom(env, "value"), enif_make_atom(env, "frame"), + enif_make_atom(env, "children")}; + ERL_NIF_TERM vals[5] = {enif_make_atom(env, type_str), nsstring_to_term(env, text), + nsstring_to_term(env, value), frame, children}; ERL_NIF_TERM result; enif_make_map_from_arrays(env, keys, vals, 5, &result); return result; @@ -3123,40 +3483,33 @@ static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TER __block ERL_NIF_TERM windows_list = enif_make_list(env, 0); __block CGSize screen_size = CGSizeZero; dispatch_sync(dispatch_get_main_queue(), ^{ - screen_size = [UIScreen mainScreen].bounds.size; - NSMutableArray *wins = [NSMutableArray array]; - for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { - if (![s isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in [(UIWindowScene *)s windows]) { - if (!w.isHidden) [wins addObject:w]; - } - } - for (NSInteger i = (NSInteger)wins.count - 1; i >= 0; i--) { - ERL_NIF_TERM wnode = build_view_node(env, wins[i], 0); - windows_list = enif_make_list_cell(env, wnode, windows_list); - } + screen_size = [UIScreen mainScreen].bounds.size; + NSMutableArray *wins = [NSMutableArray array]; + for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { + if (![s isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in [(UIWindowScene *)s windows]) { + if (!w.isHidden) + [wins addObject:w]; + } + } + for (NSInteger i = (NSInteger)wins.count - 1; i >= 0; i--) { + ERL_NIF_TERM wnode = build_view_node(env, wins[i], 0); + windows_list = enif_make_list_cell(env, wnode, windows_list); + } }); // Synthetic root wrapping all top-level windows. Frame is the screen size // so consumers always have a valid bounding box for the whole UI. - ERL_NIF_TERM root_keys[5] = { - enif_make_atom(env, "type"), - enif_make_atom(env, "label"), - enif_make_atom(env, "value"), - enif_make_atom(env, "frame"), - enif_make_atom(env, "children") - }; + ERL_NIF_TERM root_keys[5] = {enif_make_atom(env, "type"), enif_make_atom(env, "label"), + enif_make_atom(env, "value"), enif_make_atom(env, "frame"), + enif_make_atom(env, "children")}; ERL_NIF_TERM root_vals[5] = { - enif_make_atom(env, "root"), - enif_make_atom(env, "nil"), - enif_make_atom(env, "nil"), - enif_make_tuple4(env, - enif_make_double(env, 0.0), - enif_make_double(env, 0.0), - enif_make_double(env, screen_size.width), - enif_make_double(env, screen_size.height)), - windows_list - }; + enif_make_atom(env, "root"), enif_make_atom(env, "nil"), enif_make_atom(env, "nil"), + enif_make_tuple4(env, enif_make_double(env, 0.0), enif_make_double(env, 0.0), + enif_make_double(env, screen_size.width), + enif_make_double(env, screen_size.height)), + windows_list}; ERL_NIF_TERM root; enif_make_map_from_arrays(env, root_keys, root_vals, 5, &root); return root; @@ -3172,46 +3525,36 @@ static ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM __block CGFloat scale = 1.0; __block UIEdgeInsets insets = UIEdgeInsetsZero; dispatch_sync(dispatch_get_main_queue(), ^{ - UIScreen *screen = [UIScreen mainScreen]; - bounds = screen.bounds; - scale = screen.scale; - // Pull safe-area from the first visible window we find. - for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { - if (![s isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in [(UIWindowScene *)s windows]) { - if (!w.isHidden) { insets = w.safeAreaInsets; goto done; } - } - } - done:; + UIScreen *screen = [UIScreen mainScreen]; + bounds = screen.bounds; + scale = screen.scale; + // Pull safe-area from the first visible window we find. + for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { + if (![s isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in [(UIWindowScene *)s windows]) { + if (!w.isHidden) { + insets = w.safeAreaInsets; + goto done; + } + } + } + done:; }); - ERL_NIF_TERM sa_keys[4] = { - enif_make_atom(env, "top"), - enif_make_atom(env, "bottom"), - enif_make_atom(env, "left"), - enif_make_atom(env, "right") - }; + ERL_NIF_TERM sa_keys[4] = {enif_make_atom(env, "top"), enif_make_atom(env, "bottom"), + enif_make_atom(env, "left"), enif_make_atom(env, "right")}; ERL_NIF_TERM sa_vals[4] = { - enif_make_double(env, insets.top), - enif_make_double(env, insets.bottom), - enif_make_double(env, insets.left), - enif_make_double(env, insets.right) - }; + enif_make_double(env, insets.top), enif_make_double(env, insets.bottom), + enif_make_double(env, insets.left), enif_make_double(env, insets.right)}; ERL_NIF_TERM safe_area; enif_make_map_from_arrays(env, sa_keys, sa_vals, 4, &safe_area); - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "width"), - enif_make_atom(env, "height"), - enif_make_atom(env, "scale"), - enif_make_atom(env, "safe_area") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(env, bounds.size.width), - enif_make_double(env, bounds.size.height), - enif_make_double(env, scale), - safe_area - }; + ERL_NIF_TERM keys[4] = {enif_make_atom(env, "width"), enif_make_atom(env, "height"), + enif_make_atom(env, "scale"), enif_make_atom(env, "safe_area")}; + ERL_NIF_TERM vals[4] = {enif_make_double(env, bounds.size.width), + enif_make_double(env, bounds.size.height), enif_make_double(env, scale), + safe_area}; ERL_NIF_TERM result; enif_make_map_from_arrays(env, keys, vals, 4, &result); return result; @@ -3225,10 +3568,11 @@ static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // Probe via macOS AXUIElement — runs on NIF thread, no main-queue needed. Boolean trusted = g_AXIsTrusted ? g_AXIsTrusted() : NO; ERL_NIF_TERM trusted_t = enif_make_atom(env, trusted ? "trusted" : "not_trusted"); - ERL_NIF_TERM appsvc_t = enif_make_atom(env, g_AppSvc ? "loaded" : "not_loaded"); - result = enif_make_list_cell(env, enif_make_tuple2(env, - enif_make_atom(env, "ax_status"), - enif_make_tuple2(env, appsvc_t, trusted_t)), result); + ERL_NIF_TERM appsvc_t = enif_make_atom(env, g_AppSvc ? "loaded" : "not_loaded"); + result = enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, "ax_status"), + enif_make_tuple2(env, appsvc_t, trusted_t)), + result); if (g_ax_load_error) { result = enif_make_list_cell(env, nsstring_to_term(env, g_ax_load_error), result); } @@ -3246,25 +3590,27 @@ static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar return reversed; } - // ensure_a11y_enabled: no-op in the NIF itself. // Accessibility must be activated from the Mac side before calling ui_tree(): -// xcrun simctl spawn defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool YES -// xcrun simctl spawn notifyutil -p com.apple.accessibility.voiceover.status.changed +// xcrun simctl spawn defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool +// YES xcrun simctl spawn notifyutil -p com.apple.accessibility.voiceover.status.changed // pegleg_dev's `mix mob.connect` will do this automatically. -static void ensure_a11y_enabled(void) { } +static void ensure_a11y_enabled(void) { +} static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block ERL_NIF_TERM list = enif_make_list(env, 0); dispatch_sync(dispatch_get_main_queue(), ^{ - ensure_a11y_enabled(); - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *window in [(UIWindowScene *)scene windows]) { - if (window.isHidden) continue; - walk_a11y(env, window, &list, 0); - } - } + ensure_a11y_enabled(); + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *window in [(UIWindowScene *)scene windows]) { + if (window.isHidden) + continue; + walk_a11y(env, window, &list, 0); + } + } }); ERL_NIF_TERM reversed; enif_make_reverse_list(env, list, &reversed); @@ -3288,7 +3634,8 @@ static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM arg // Returns the deepest accessibility element whose frame contains 'pt'. // Walks children depth-first (deepest/most-specific match wins). static id find_a11y_at_point(id obj, CGPoint pt, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; // Recurse into children first (deepest match wins) if ([obj respondsToSelector:@selector(accessibilityElements)]) { @@ -3297,7 +3644,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_at_point(child, pt, depth + 1); - if (found) return found; + if (found) + return found; } } goto check_self; @@ -3310,7 +3658,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_at_point(child, pt, depth + 1); - if (found) return found; + if (found) + return found; } } goto check_self; @@ -3319,7 +3668,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_at_point(sub, pt, depth + 1); - if (found) return found; + if (found) + return found; } } @@ -3328,19 +3678,23 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { [(id)obj isAccessibilityElement] && [obj respondsToSelector:@selector(accessibilityFrame)]) { CGRect frame = [(id)obj accessibilityFrame]; - if (CGRectContainsPoint(frame, pt)) return obj; + if (CGRectContainsPoint(frame, pt)) + return obj; } return nil; } static id find_a11y_by_label(id obj, NSString *target, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; if ([obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]) { NSString *lbl = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; - if ([lbl isEqualToString:target]) return obj; + ? [(id)obj accessibilityLabel] + : nil; + if ([lbl isEqualToString:target]) + return obj; } // Walk children via the same single-path logic as walk_a11y() to avoid duplicates. @@ -3350,7 +3704,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_by_label(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3363,7 +3718,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_by_label(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3372,7 +3728,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_by_label(sub, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3381,35 +3738,37 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // Accept Elixir binary strings (the normal case) ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); NSString *label = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - if (!label) return enif_make_badarg(env); + if (!label) + return enif_make_badarg(env); __block BOOL activated = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *window in [(UIWindowScene *)scene windows]) { - if (window.isHidden) continue; - id elem = find_a11y_by_label(window, label, 0); - if (elem) { - [elem accessibilityActivate]; - activated = YES; - return; - } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *window in [(UIWindowScene *)scene windows]) { + if (window.isHidden) + continue; + id elem = find_a11y_by_label(window, label, 0); + if (elem) { + [elem accessibilityActivate]; + activated = YES; + return; + } + } + } }); - if (activated) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_found")); + if (activated) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_found")); } - // ── ax_action/2 — invoke an accessibility action on an element ──────────────── // // Finds the first AX element whose label OR value contains `match`, then sends @@ -3432,14 +3791,17 @@ static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) // IMPORTANT: this requires accessibility to be activated (VoiceOver on, or // similar AX-client toggle). Same constraint as ui_tree/0. static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; if ([obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]) { NSString *lbl = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; + ? [(id)obj accessibilityLabel] + : nil; NSString *val = [obj respondsToSelector:@selector(accessibilityValue)] - ? [(id)obj accessibilityValue] : nil; + ? [(id)obj accessibilityValue] + : nil; if ((lbl && [lbl rangeOfString:target].location != NSNotFound) || (val && [val rangeOfString:target].location != NSNotFound)) { return obj; @@ -3452,7 +3814,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_by_label_or_value(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3465,7 +3828,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_by_label_or_value(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3474,7 +3838,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_by_label_or_value(sub, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3482,10 +3847,13 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString *match = [[NSString alloc] initWithBytes:bin.data length:bin.size + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + NSString *match = [[NSString alloc] initWithBytes:bin.data + length:bin.size encoding:NSUTF8StringEncoding]; - if (!match) return enif_make_badarg(env); + if (!match) + return enif_make_badarg(env); char action_buf[32] = {0}; if (!enif_get_atom(env, argv[1], action_buf, sizeof(action_buf), ERL_NIF_LATIN1)) @@ -3494,53 +3862,64 @@ static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM a __block id elem = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - elem = find_a11y_by_label_or_value(win, match, 0); - if (elem) return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + elem = find_a11y_by_label_or_value(win, match, 0); + if (elem) + return; + } + } }); - if (!elem) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_found")); + if (!elem) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_found")); __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - if ([action isEqualToString:@"increment"]) { - if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { - [elem accessibilityIncrement]; ok = YES; - } - } else if ([action isEqualToString:@"decrement"]) { - if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { - [elem accessibilityDecrement]; ok = YES; - } - } else if ([action isEqualToString:@"activate"]) { - if ([elem respondsToSelector:@selector(accessibilityActivate)]) { - ok = [elem accessibilityActivate]; - } - } else if ([action isEqualToString:@"escape"]) { - if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { - ok = [elem accessibilityPerformEscape]; - } - } else if ([action hasPrefix:@"scroll_"]) { - NSString *dir_str = [action substringFromIndex:7]; - UIAccessibilityScrollDirection dir = 0; - if ([dir_str isEqualToString:@"up"]) dir = UIAccessibilityScrollDirectionUp; - else if ([dir_str isEqualToString:@"down"]) dir = UIAccessibilityScrollDirectionDown; - else if ([dir_str isEqualToString:@"left"]) dir = UIAccessibilityScrollDirectionLeft; - else if ([dir_str isEqualToString:@"right"]) dir = UIAccessibilityScrollDirectionRight; - if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { - ok = [elem accessibilityScroll:dir]; - } - } + if ([action isEqualToString:@"increment"]) { + if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { + [elem accessibilityIncrement]; + ok = YES; + } + } else if ([action isEqualToString:@"decrement"]) { + if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { + [elem accessibilityDecrement]; + ok = YES; + } + } else if ([action isEqualToString:@"activate"]) { + if ([elem respondsToSelector:@selector(accessibilityActivate)]) { + ok = [elem accessibilityActivate]; + } + } else if ([action isEqualToString:@"escape"]) { + if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { + ok = [elem accessibilityPerformEscape]; + } + } else if ([action hasPrefix:@"scroll_"]) { + NSString *dir_str = [action substringFromIndex:7]; + UIAccessibilityScrollDirection dir = 0; + if ([dir_str isEqualToString:@"up"]) + dir = UIAccessibilityScrollDirectionUp; + else if ([dir_str isEqualToString:@"down"]) + dir = UIAccessibilityScrollDirectionDown; + else if ([dir_str isEqualToString:@"left"]) + dir = UIAccessibilityScrollDirectionLeft; + else if ([dir_str isEqualToString:@"right"]) + dir = UIAccessibilityScrollDirectionRight; + if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { + ok = [elem accessibilityScroll:dir]; + } + } }); - if (ok) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "action_failed")); + if (ok) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "action_failed")); } // ── ax_action_at_xy/3 — invoke an AX action on whatever element is at (x, y) ── @@ -3563,56 +3942,66 @@ static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_ CGPoint pt = CGPointMake((CGFloat)x, (CGFloat)y); __block id elem = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - elem = find_a11y_at_point(win, pt, 0); - if (elem) return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + elem = find_a11y_at_point(win, pt, 0); + if (elem) + return; + } + } }); - if (!elem) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "no_element_at_point")); + if (!elem) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_element_at_point")); __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - if ([action isEqualToString:@"increment"]) { - if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { - [elem accessibilityIncrement]; ok = YES; - } - } else if ([action isEqualToString:@"decrement"]) { - if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { - [elem accessibilityDecrement]; ok = YES; - } - } else if ([action isEqualToString:@"activate"]) { - if ([elem respondsToSelector:@selector(accessibilityActivate)]) { - ok = [elem accessibilityActivate]; - } - } else if ([action isEqualToString:@"escape"]) { - if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { - ok = [elem accessibilityPerformEscape]; - } - } else if ([action hasPrefix:@"scroll_"]) { - NSString *dir_str = [action substringFromIndex:7]; - UIAccessibilityScrollDirection dir = 0; - if ([dir_str isEqualToString:@"up"]) dir = UIAccessibilityScrollDirectionUp; - else if ([dir_str isEqualToString:@"down"]) dir = UIAccessibilityScrollDirectionDown; - else if ([dir_str isEqualToString:@"left"]) dir = UIAccessibilityScrollDirectionLeft; - else if ([dir_str isEqualToString:@"right"]) dir = UIAccessibilityScrollDirectionRight; - if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { - ok = [elem accessibilityScroll:dir]; - } - } + if ([action isEqualToString:@"increment"]) { + if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { + [elem accessibilityIncrement]; + ok = YES; + } + } else if ([action isEqualToString:@"decrement"]) { + if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { + [elem accessibilityDecrement]; + ok = YES; + } + } else if ([action isEqualToString:@"activate"]) { + if ([elem respondsToSelector:@selector(accessibilityActivate)]) { + ok = [elem accessibilityActivate]; + } + } else if ([action isEqualToString:@"escape"]) { + if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { + ok = [elem accessibilityPerformEscape]; + } + } else if ([action hasPrefix:@"scroll_"]) { + NSString *dir_str = [action substringFromIndex:7]; + UIAccessibilityScrollDirection dir = 0; + if ([dir_str isEqualToString:@"up"]) + dir = UIAccessibilityScrollDirectionUp; + else if ([dir_str isEqualToString:@"down"]) + dir = UIAccessibilityScrollDirectionDown; + else if ([dir_str isEqualToString:@"left"]) + dir = UIAccessibilityScrollDirectionLeft; + else if ([dir_str isEqualToString:@"right"]) + dir = UIAccessibilityScrollDirectionRight; + if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { + ok = [elem accessibilityScroll:dir]; + } + } }); - if (ok) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "action_failed")); + if (ok) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "action_failed")); } - // ─── tap_xy/2 — Phase 3: real UITouch injection at screen coordinates ───────── // // Synthesises genuine UITouch/UIEvent objects and delivers them through UIKit's @@ -3684,13 +4073,13 @@ - (void)_clearTouches; - (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed; // iOS 26+ — create bare event backed by IOHIDEvent - (instancetype)_init; -- (void)_setHIDEvent:(CFTypeRef)hidEvent; // back UIEvent with IOHIDEventRef +- (void)_setHIDEvent:(CFTypeRef)hidEvent; // back UIEvent with IOHIDEventRef @end @interface UITouch (MobPhase3) // Private on all iOS versions - (void)_setLocationInWindow:(CGPoint)pt resetPrevious:(BOOL)reset; -- (void)_setHidEvent:(CFTypeRef)hidEvent; // per-touch HID backing (lowercase 'id') +- (void)_setHidEvent:(CFTypeRef)hidEvent; // per-touch HID backing (lowercase 'id') // Private on iOS < 26, GONE on iOS 26 (replaced by public setters below) - (void)_setWindow:(UIWindow *)window; - (void)_setView:(UIView *)view; @@ -3707,16 +4096,15 @@ - (void)setTapCount:(NSUInteger)n; // Preserved UITouch from Began phase — reused for Ended/Cancelled so that // the touch object's pointer identity remains stable across phases. -static UITouch * __strong sSavedTouch = nil; +static UITouch *__strong sSavedTouch = nil; // IOHIDEventCreateDigitizerFingerEvent — resolved once via dlsym. typedef CFTypeRef IOHIDEventRef_t; -typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)( - CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t, - double, double, double, double, double, bool, bool, uint32_t -); +typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, + uint32_t, double, double, double, double, double, + bool, bool, uint32_t); static IOHIDCreateFingerFn sIOHIDCreateFinger; -static dispatch_once_t sIOHIDOnce; +static dispatch_once_t sIOHIDOnce; // ── Core touch-phase helper ──────────────────────────────────────────────────── // Delivers one touch phase to UIKit. @@ -3730,54 +4118,52 @@ typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)( // then [UIWindow sendEvent:]. // // Returns NO if the required APIs are missing on this iOS version. -static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, - CGPoint pt, UITouchPhase phase) { +static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, CGPoint pt, + UITouchPhase phase) { // ── iOS 26+ path: pure IOHIDEvent → _handleHIDEvent: ───────────────────────── // Let UIKit create UITouch and dispatch through its full pipeline. // Both Began and Ended go through _handleHIDEvent: — no manual UITouch injection, // no [window sendEvent:]. UIKit routes based on the window's contextId. { dispatch_once(&sIOHIDOnce, ^{ - sIOHIDCreateFinger = - dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent"); + sIOHIDCreateFinger = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent"); }); SEL handleSel = NSSelectorFromString(@"_handleHIDEvent:"); UIApplication *app = [UIApplication sharedApplication]; if (!sIOHIDCreateFinger || ![app respondsToSelector:handleSel]) { - LOGE(@"tap_xy: IOHIDCreateFinger=%p handleHIDEvent=%d", - (void *)sIOHIDCreateFinger, (int)[app respondsToSelector:handleSel]); + LOGE(@"tap_xy: IOHIDCreateFinger=%p handleHIDEvent=%d", (void *)sIOHIDCreateFinger, + (int)[app respondsToSelector:handleSel]); return NO; } CGSize screen = [UIScreen mainScreen].bounds.size; - double normX = pt.x / screen.width; - double normY = pt.y / screen.height; - uint64_t ts = mach_absolute_time(); + double normX = pt.x / screen.width; + double normY = pt.y / screen.height; + uint64_t ts = mach_absolute_time(); // fingerDown=YES for Began/Moved, NO for Ended/Cancelled BOOL fingerDown = (phase == UITouchPhaseBegan || phase == UITouchPhaseMoved); - IOHIDEventRef_t hidEvent = sIOHIDCreateFinger( - kCFAllocatorDefault, ts, - 0u, // fingerIndex - 1u, // identity - 1u | 2u | 4u, // eventMask: Range | Touch | Position - normX, normY, 0.0, - fingerDown ? 1.0 : 0.0, // tipPressure: 1.0 down, 0.0 up - 0.0, - (bool)fingerDown, // range: finger in digitizer range? - (bool)fingerDown, // touch: finger touching? - 0u - ); + IOHIDEventRef_t hidEvent = + sIOHIDCreateFinger(kCFAllocatorDefault, ts, + 0u, // fingerIndex + 1u, // identity + 1u | 2u | 4u, // eventMask: Range | Touch | Position + normX, normY, 0.0, + fingerDown ? 1.0 : 0.0, // tipPressure: 1.0 down, 0.0 up + 0.0, + (bool)fingerDown, // range: finger in digitizer range? + (bool)fingerDown, // touch: finger touching? + 0u); if (!hidEvent) { LOGE(@"tap_xy: IOHIDEventCreateDigitizerFingerEvent returned nil"); return NO; } - LOGI(@"tap_xy: _handleHIDEvent: phase=%d normX=%.3f normY=%.3f fingerDown=%d", - (int)phase, normX, normY, (int)fingerDown); + LOGI(@"tap_xy: _handleHIDEvent: phase=%d normX=%.3f normY=%.3f fingerDown=%d", (int)phase, + normX, normY, (int)fingerDown); typedef void (*HandleFn)(id, SEL, CFTypeRef); ((HandleFn)objc_msgSend)(app, handleSel, hidEvent); @@ -3785,8 +4171,8 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // Check what UIKit created — did it produce a UITouch? if ([app respondsToSelector:@selector(_touchesEvent)]) { UIEvent *ev = [app _touchesEvent]; - LOGI(@"tap_xy: post-handleHID: _touchesEvent=%p allTouches=%lu", - (__bridge void *)ev, (unsigned long)ev.allTouches.count); + LOGI(@"tap_xy: post-handleHID: _touchesEvent=%p allTouches=%lu", (__bridge void *)ev, + (unsigned long)ev.allTouches.count); if (ev && ev.allTouches.count > 0) { // UIKit created a UITouch — dispatch via the correct window LOGI(@"tap_xy: dispatching via [window sendEvent:] with UIKit-created touch"); @@ -3796,7 +4182,7 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, CFRelease(hidEvent); return YES; - } // end iOS 26+ pure-HID block + } // end iOS 26+ pure-HID block // ── iOS <26 path: manual UITouch + UIEvent ──────────────────────────────── // UITouch private setters + _touchesEvent + _addTouch:forDelayedDelivery:. @@ -3809,7 +4195,10 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // window setter if ([touch respondsToSelector:@selector(_setWindow:)]) [touch _setWindow:window]; - else { LOGE(@"tap_xy (<26): no _setWindow: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setWindow: on UITouch"); + return NO; + } // view setter (best-effort; nil is tolerated by some iOS versions) if ([touch respondsToSelector:@selector(_setView:)]) @@ -3818,7 +4207,10 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // phase setter if ([touch respondsToSelector:@selector(_setPhase:)]) [touch _setPhase:phase]; - else { LOGE(@"tap_xy (<26): no _setPhase: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setPhase: on UITouch"); + return NO; + } // timestamp if ([touch respondsToSelector:@selector(_setTimestamp:)]) @@ -3831,18 +4223,25 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // location if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) [touch _setLocationInWindow:pt resetPrevious:(phase == UITouchPhaseBegan)]; - else { LOGE(@"tap_xy (<26): no _setLocationInWindow:resetPrevious: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setLocationInWindow:resetPrevious: on UITouch"); + return NO; + } // build UIEvent if (![app respondsToSelector:@selector(_touchesEvent)]) { - LOGE(@"tap_xy (<26): no _touchesEvent on UIApplication"); return NO; + LOGE(@"tap_xy (<26): no _touchesEvent on UIApplication"); + return NO; } UIEvent *event = [app _touchesEvent]; if ([event respondsToSelector:@selector(_clearTouches)]) [event _clearTouches]; if ([event respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) [event _addTouch:touch forDelayedDelivery:NO]; - else { LOGE(@"tap_xy (<26): no _addTouch:forDelayedDelivery:"); return NO; } + else { + LOGE(@"tap_xy (<26): no _addTouch:forDelayedDelivery:"); + return NO; + } [window sendEvent:event]; return YES; @@ -3870,29 +4269,35 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { UITouch *touch = [[UITouch alloc] init]; UIEvent *fakeEvent = [UIEvent new]; - struct { const char *name; BOOL found; } checks[] = { - {"UIApp._touchesEvent", [app respondsToSelector:@selector(_touchesEvent)]}, + struct { + const char *name; + BOOL found; + } checks[] = { + {"UIApp._touchesEvent", [app respondsToSelector:@selector(_touchesEvent)]}, // UITouch — old private names (iOS <26) - {"UITouch._setWindow:", [touch respondsToSelector:@selector(_setWindow:)]}, - {"UITouch._setView:", [touch respondsToSelector:@selector(_setView:)]}, - {"UITouch._setPhase:", [touch respondsToSelector:@selector(_setPhase:)]}, - {"UITouch._setTimestamp:", [touch respondsToSelector:@selector(_setTimestamp:)]}, - {"UITouch._setTapCount:", [touch respondsToSelector:@selector(_setTapCount:)]}, - {"UITouch._setLocationInWindow:resetPrevious:", [touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]}, + {"UITouch._setWindow:", [touch respondsToSelector:@selector(_setWindow:)]}, + {"UITouch._setView:", [touch respondsToSelector:@selector(_setView:)]}, + {"UITouch._setPhase:", [touch respondsToSelector:@selector(_setPhase:)]}, + {"UITouch._setTimestamp:", [touch respondsToSelector:@selector(_setTimestamp:)]}, + {"UITouch._setTapCount:", [touch respondsToSelector:@selector(_setTapCount:)]}, + {"UITouch._setLocationInWindow:resetPrevious:", + [touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]}, // UITouch — iOS 26+ names (no underscore) - {"UITouch.setWindow:", [touch respondsToSelector:@selector(setWindow:)]}, - {"UITouch.setView:", [touch respondsToSelector:@selector(setView:)]}, - {"UITouch.setPhase:", [touch respondsToSelector:@selector(setPhase:)]}, - {"UITouch.setTimestamp:", [touch respondsToSelector:@selector(setTimestamp:)]}, - {"UITouch.setTapCount:", [touch respondsToSelector:@selector(setTapCount:)]}, + {"UITouch.setWindow:", [touch respondsToSelector:@selector(setWindow:)]}, + {"UITouch.setView:", [touch respondsToSelector:@selector(setView:)]}, + {"UITouch.setPhase:", [touch respondsToSelector:@selector(setPhase:)]}, + {"UITouch.setTimestamp:", [touch respondsToSelector:@selector(setTimestamp:)]}, + {"UITouch.setTapCount:", [touch respondsToSelector:@selector(setTapCount:)]}, // UIEvent — old private names (iOS <26) - {"UIEvent._clearTouches", [fakeEvent respondsToSelector:@selector(_clearTouches)]}, - {"UIEvent._addTouch:forDelayedDelivery:", [fakeEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]}, + {"UIEvent._clearTouches", [fakeEvent respondsToSelector:@selector(_clearTouches)]}, + {"UIEvent._addTouch:forDelayedDelivery:", + [fakeEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]}, // UIEvent — iOS 26+ - {"UIEvent._initWithEvent:touches:", [UIEvent instancesRespondToSelector:@selector(_initWithEvent:touches:)]}, + {"UIEvent._initWithEvent:touches:", + [UIEvent instancesRespondToSelector:@selector(_initWithEvent:touches:)]}, // UITouch HID backing - {"UITouch._setHidEvent:", [touch respondsToSelector:@selector(_setHidEvent:)]}, - {"UITouch._hidEvent", [touch respondsToSelector:@selector(_hidEvent)]}, + {"UITouch._setHidEvent:", [touch respondsToSelector:@selector(_setHidEvent:)]}, + {"UITouch._hidEvent", [touch respondsToSelector:@selector(_hidEvent)]}, }; ERL_NIF_TERM list = enif_make_list(env, 0); @@ -3912,29 +4317,28 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { // enc looks like "@24@0:8@16@16" — arg0 is return (id), arg2 is self, // arg3 is SEL, arg4 is first real arg. We want arg4's type. ERL_NIF_TERM enc_term = enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIEvent._initWithEvent:touches:.encoding"), - enc_term), + list = enif_make_list_cell( + env, + enif_make_tuple2( + env, enif_make_atom(env, "UIEvent._initWithEvent:touches:.encoding"), enc_term), list); } } // Test _initWithEvent: with empty NSSet to isolate whether UITouch or base causes nil return. { - UIEvent *baseInit = [UIEvent instancesRespondToSelector:@selector(_init)] - ? [[UIEvent alloc] _init] : nil; + UIEvent *baseInit = + [UIEvent instancesRespondToSelector:@selector(_init)] ? [[UIEvent alloc] _init] : nil; SEL initWithEvSel = NSSelectorFromString(@"_initWithEvent:touches:"); - typedef UIEvent* (*InitWithEvFn)(id, SEL, void*, NSSet*); - UIEvent *testEmpty = [UIEvent instancesRespondToSelector:initWithEvSel] - ? ((InitWithEvFn)objc_msgSend)([[UIEvent alloc] init], initWithEvSel, (__bridge void *)baseInit, [NSSet set]) - : nil; + typedef UIEvent *(*InitWithEvFn)(id, SEL, void *, NSSet *); + UIEvent *testEmpty = + [UIEvent instancesRespondToSelector:initWithEvSel] + ? ((InitWithEvFn)objc_msgSend)([[UIEvent alloc] init], initWithEvSel, + (__bridge void *)baseInit, [NSSet set]) + : nil; ERL_NIF_TERM val = enif_make_atom(env, testEmpty ? "non_nil" : "nil"); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "_initWithEvent:emptySet"), - val), - list); + list = enif_make_list_cell( + env, enif_make_tuple2(env, enif_make_atom(env, "_initWithEvent:emptySet"), val), list); } // Type encoding of UIEvent._setHIDEvent: to learn what it takes. @@ -3942,10 +4346,10 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { Method m = class_getInstanceMethod([UIEvent class], @selector(_setHIDEvent:)); if (m) { const char *enc = method_getTypeEncoding(m); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIEvent._setHIDEvent:.encoding"), - enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1)), + list = enif_make_list_cell( + env, + enif_make_tuple2(env, enif_make_atom(env, "UIEvent._setHIDEvent:.encoding"), + enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1)), list); } } @@ -3953,15 +4357,17 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { // Check if IOHIDEventCreate* functions are available (for direct HID injection). { BOOL hasCreateFinger = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent") != NULL; - BOOL hasCreateFingerQ = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEventWithQuality") != NULL; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEvent"), - enif_make_atom(env, hasCreateFinger ? "true" : "false")), + BOOL hasCreateFingerQ = + dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEventWithQuality") != NULL; + list = enif_make_list_cell( + env, + enif_make_tuple2(env, enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEvent"), + enif_make_atom(env, hasCreateFinger ? "true" : "false")), list); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEventWithQuality"), + list = enif_make_list_cell( + env, + enif_make_tuple2( + env, enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEventWithQuality"), enif_make_atom(env, hasCreateFingerQ ? "true" : "false")), list); } @@ -3970,27 +4376,26 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { { UIApplication *a = [UIApplication sharedApplication]; BOOL hasHandle = [a respondsToSelector:NSSelectorFromString(@"_handleHIDEvent:")]; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIApp._handleHIDEvent:"), - enif_make_atom(env, hasHandle ? "true" : "false")), - list); + list = + enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, "UIApp._handleHIDEvent:"), + enif_make_atom(env, hasHandle ? "true" : "false")), + list); } // Check for GSSendSystemEvent / GSSynthesizeSystemEvent via dlsym. { const char *gsFuncs[] = { - "GSSendSystemEvent", "GSSynthesizeSystemEvent", - "GSSendEvent", "GSEventDispatch", - "GSSendSystemEventFast", + "GSSendSystemEvent", "GSSynthesizeSystemEvent", "GSSendEvent", + "GSEventDispatch", "GSSendSystemEventFast", }; for (int i = 0; i < 5; i++) { BOOL found = dlsym(RTLD_DEFAULT, gsFuncs[i]) != NULL; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, gsFuncs[i]), - enif_make_atom(env, found ? "true" : "false")), - list); + list = + enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, gsFuncs[i]), + enif_make_atom(env, found ? "true" : "false")), + list); } } @@ -4000,7 +4405,8 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // Diagnostics mode — pass :probe or :enumerate_touch or :enumerate_event if (enif_is_atom(env, argv[0])) { - char atom[64]; enif_get_atom(env, argv[0], atom, sizeof(atom), ERL_NIF_LATIN1); + char atom[64]; + enif_get_atom(env, argv[0], atom, sizeof(atom), ERL_NIF_LATIN1); if (strcmp(atom, "enumerate_touch") == 0) return nif_tap_xy_enumerate(env, [UITouch class], NULL); if (strcmp(atom, "enumerate_event") == 0) @@ -4022,7 +4428,7 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv ERL_NIF_TERM list = enif_make_list(env, 0); for (unsigned int i = 0; i < count; i++) { const char *name = ivar_getName(ivars[i]); - ptrdiff_t off = ivar_getOffset(ivars[i]); + ptrdiff_t off = ivar_getOffset(ivars[i]); const char *type = ivar_getTypeEncoding(ivars[i]); char buf[256]; snprintf(buf, sizeof(buf), "%s@%td(%s)", name ? name : "?", off, type ? type : "?"); @@ -4042,42 +4448,45 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv if (strcmp(atom, "window_info") == 0) { __block ERL_NIF_TERM result = enif_make_atom(env, "no_window"); dispatch_sync(dispatch_get_main_queue(), ^{ - UIWindow *win = nil; - for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) { - if ([sc isKindOfClass:[UIWindowScene class]]) { - for (UIWindow *w in [(UIWindowScene *)sc windows]) { - if (!w.isHidden) { win = w; break; } - } - if (win) break; - } - } - if (!win) return; - - // Try various contextId getters - uint32_t ctxId = 0; - SEL ctxSels[] = { - @selector(_contextId), - @selector(_windowContextID), - @selector(contextId), - @selector(_displayID), - }; - NSString *ctxSelName = @"none"; - for (int i = 0; i < 4; i++) { - if ([win respondsToSelector:ctxSels[i]]) { - typedef uint32_t (*GetU32Fn)(id, SEL); - ctxId = ((GetU32Fn)objc_msgSend)(win, ctxSels[i]); - ctxSelName = NSStringFromSelector(ctxSels[i]); - break; - } - } - - char buf[256]; - snprintf(buf, sizeof(buf), "win=%p class=%s ctxSel=%s ctxId=0x%08x", - (__bridge void *)win, - class_getName(object_getClass(win)), - [ctxSelName UTF8String], - ctxId); - result = enif_make_string(env, buf, ERL_NIF_LATIN1); + UIWindow *win = nil; + for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) { + if ([sc isKindOfClass:[UIWindowScene class]]) { + for (UIWindow *w in [(UIWindowScene *)sc windows]) { + if (!w.isHidden) { + win = w; + break; + } + } + if (win) + break; + } + } + if (!win) + return; + + // Try various contextId getters + uint32_t ctxId = 0; + SEL ctxSels[] = { + @selector(_contextId), + @selector(_windowContextID), + @selector(contextId), + @selector(_displayID), + }; + NSString *ctxSelName = @"none"; + for (int i = 0; i < 4; i++) { + if ([win respondsToSelector:ctxSels[i]]) { + typedef uint32_t (*GetU32Fn)(id, SEL); + ctxId = ((GetU32Fn)objc_msgSend)(win, ctxSels[i]); + ctxSelName = NSStringFromSelector(ctxSels[i]); + break; + } + } + + char buf[256]; + snprintf(buf, sizeof(buf), "win=%p class=%s ctxSel=%s ctxId=0x%08x", + (__bridge void *)win, class_getName(object_getClass(win)), + [ctxSelName UTF8String], ctxId); + result = enif_make_string(env, buf, ERL_NIF_LATIN1); }); return result; } @@ -4085,10 +4494,16 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv } double x, y; if (!enif_get_double(env, argv[0], &x)) { - int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; + int ix; + if (!enif_get_int(env, argv[0], &ix)) + return enif_make_badarg(env); + x = ix; } if (!enif_get_double(env, argv[1], &y)) { - int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; + int iy; + if (!enif_get_int(env, argv[1], &iy)) + return enif_make_badarg(env); + y = iy; } CGPoint pt = CGPointMake(x, y); @@ -4102,89 +4517,95 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv // a simulator-specific event injection mechanism would be needed. __block BOOL activated = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id elem = find_a11y_at_point(win, pt, 0); - if (elem) { - LOGI(@"tap_xy(sim): accessibilityActivate on %@ frame=%@", - NSStringFromClass(object_getClass(elem)), - NSStringFromCGRect([elem accessibilityFrame])); - [elem accessibilityActivate]; - // For text fields: accessibilityActivate on UITextFieldLabel - // (the hint label inside UITextField) doesn't focus the - // field. Walk the responder chain up from the hit view to - // find the first UITextField/UITextView and focus it. - UIView *hv = [win hitTest:pt withEvent:nil]; - UIResponder *r = hv; - while (r) { - if ([r isKindOfClass:[UITextField class]] || - [r isKindOfClass:[UITextView class]]) { - [(UIView *)r becomeFirstResponder]; - break; - } - r = r.nextResponder; - } - activated = YES; - return; - } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id elem = find_a11y_at_point(win, pt, 0); + if (elem) { + LOGI(@"tap_xy(sim): accessibilityActivate on %@ frame=%@", + NSStringFromClass(object_getClass(elem)), + NSStringFromCGRect([elem accessibilityFrame])); + [elem accessibilityActivate]; + // For text fields: accessibilityActivate on UITextFieldLabel + // (the hint label inside UITextField) doesn't focus the + // field. Walk the responder chain up from the hit view to + // find the first UITextField/UITextView and focus it. + UIView *hv = [win hitTest:pt withEvent:nil]; + UIResponder *r = hv; + while (r) { + if ([r isKindOfClass:[UITextField class]] || + [r isKindOfClass:[UITextView class]]) { + [(UIView *)r becomeFirstResponder]; + break; + } + r = r.nextResponder; + } + activated = YES; + return; + } + } + } }); - if (activated) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_element_at_point")); + if (activated) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_element_at_point")); #else // ── Real device: UITouch injection via IOHIDEvent ───────────────────────────── __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *hit = [win hitTest:pt withEvent:nil]; - if (hit) { targetWindow = win; hitView = hit; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *hit = [win hitTest:pt withEvent:nil]; + if (hit) { + targetWindow = win; + hitView = hit; + return; + } + } + } }); if (!hitView) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); } __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - ok = mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); + ok = mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); }); if (!ok) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - nif_tap_xy_probe(env)); + return enif_make_tuple2(env, enif_make_atom(env, "error"), nif_tap_xy_probe(env)); } [NSThread sleepForTimeInterval:0.10]; dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } - static id find_first_responder_in(UIView *view) { - if (view.isFirstResponder) return view; + if (view.isFirstResponder) + return view; for (UIView *sub in view.subviews) { id fr = find_first_responder_in(sub); - if (fr) return fr; + if (fr) + return fr; } return nil; } @@ -4200,31 +4621,33 @@ static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_ __block BOOL done = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - if ([fr respondsToSelector:@selector(deleteBackward)]) { - [fr deleteBackward]; - done = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + if ([fr respondsToSelector:@selector(deleteBackward)]) { + [fr deleteBackward]; + done = YES; + } + return; + } + } }); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── key_press/1 — send a special key to the focused text input ─────────────── // // Accepts an atom: @@ -4247,53 +4670,55 @@ static ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM a __block BOOL unknown = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - - if ([key isEqualToString:@"return"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@"\n"]; - done = YES; - } - } else if ([key isEqualToString:@"tab"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@"\t"]; - done = YES; - } - } else if ([key isEqualToString:@"space"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@" "]; - done = YES; - } - } else if ([key isEqualToString:@"escape"]) { - [fr resignFirstResponder]; - done = YES; - } else { - unknown = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + + if ([key isEqualToString:@"return"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@"\n"]; + done = YES; + } + } else if ([key isEqualToString:@"tab"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@"\t"]; + done = YES; + } + } else if ([key isEqualToString:@"space"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@" "]; + done = YES; + } + } else if ([key isEqualToString:@"escape"]) { + [fr resignFirstResponder]; + done = YES; + } else { + unknown = YES; + } + return; + } + } }); - if (unknown) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "unknown_key")); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (unknown) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "unknown_key")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── clear_text/0 — erase all text in the focused input ────────────────────── // // Calls selectAll: then deleteBackward: on the first responder. Works on @@ -4305,38 +4730,40 @@ static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM __block BOOL done = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - BOOL canClear = [fr respondsToSelector:@selector(selectAll:)] && - [fr respondsToSelector:@selector(deleteBackward)]; - if (canClear) { - [fr selectAll:nil]; - // selectAll: is async in UITextView — yield once to let selection settle - // before deleting. - dispatch_async(dispatch_get_main_queue(), ^{ - [fr deleteBackward]; - }); - done = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + BOOL canClear = [fr respondsToSelector:@selector(selectAll:)] && + [fr respondsToSelector:@selector(deleteBackward)]; + if (canClear) { + [fr selectAll:nil]; + // selectAll: is async in UITextView — yield once to let selection settle + // before deleting. + dispatch_async(dispatch_get_main_queue(), ^{ + [fr deleteBackward]; + }); + done = YES; + } + return; + } + } }); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── long_press_xy/3 — hold touch at (x, y) for duration_ms milliseconds ───── // // Simulator: finds UILongPressGestureRecognizer on the hit view or its ancestors @@ -4351,8 +4778,7 @@ static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double x, y; int duration_ms; - if (!enif_get_double(env, argv[0], &x) || - !enif_get_double(env, argv[1], &y) || + if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y) || !enif_get_int(env, argv[2], &duration_ms)) return enif_make_badarg(env); @@ -4361,90 +4787,103 @@ static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TE #if TARGET_OS_SIMULATOR __block BOOL fired = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - UIView *hitView = nil; - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *h = [win hitTest:pt withEvent:nil]; - if (h) { hitView = h; break; } - } - if (hitView) break; - } - if (!hitView) return; - - // Walk up the responder chain looking for any UILongPressGestureRecognizer - SEL setStateSel = NSSelectorFromString(@"_setState:"); - UIView *v = hitView; - while (v && !fired) { - for (UIGestureRecognizer *gr in v.gestureRecognizers) { - if (![gr isKindOfClass:[UILongPressGestureRecognizer class]]) continue; - if (![gr respondsToSelector:setStateSel]) continue; - typedef void (*SetStateFn)(id, SEL, NSInteger); - SetStateFn setState = (SetStateFn)objc_msgSend; - LOGI(@"long_press_xy(sim): firing LPGR on %@", NSStringFromClass([v class])); - setState(gr, setStateSel, UIGestureRecognizerStateBegan); - setState(gr, setStateSel, UIGestureRecognizerStateEnded); - fired = YES; - break; - } - v = v.superview; - } - - // SwiftUI onLongPressGesture may also surface as an accessibility custom action. - // Try accessibilityActivate as a fallback — limited but better than nothing. - if (!fired) { - id elem = find_a11y_at_point(hitView, pt, 0); - if (elem && [elem respondsToSelector:@selector(accessibilityActivate)]) { - LOGI(@"long_press_xy(sim): fallback to accessibilityActivate on %@", - NSStringFromClass(object_getClass(elem))); - [elem accessibilityActivate]; - fired = YES; - } - } + UIView *hitView = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *h = [win hitTest:pt withEvent:nil]; + if (h) { + hitView = h; + break; + } + } + if (hitView) + break; + } + if (!hitView) + return; + + // Walk up the responder chain looking for any UILongPressGestureRecognizer + SEL setStateSel = NSSelectorFromString(@"_setState:"); + UIView *v = hitView; + while (v && !fired) { + for (UIGestureRecognizer *gr in v.gestureRecognizers) { + if (![gr isKindOfClass:[UILongPressGestureRecognizer class]]) + continue; + if (![gr respondsToSelector:setStateSel]) + continue; + typedef void (*SetStateFn)(id, SEL, NSInteger); + SetStateFn setState = (SetStateFn)objc_msgSend; + LOGI(@"long_press_xy(sim): firing LPGR on %@", NSStringFromClass([v class])); + setState(gr, setStateSel, UIGestureRecognizerStateBegan); + setState(gr, setStateSel, UIGestureRecognizerStateEnded); + fired = YES; + break; + } + v = v.superview; + } + + // SwiftUI onLongPressGesture may also surface as an accessibility custom action. + // Try accessibilityActivate as a fallback — limited but better than nothing. + if (!fired) { + id elem = find_a11y_at_point(hitView, pt, 0); + if (elem && [elem respondsToSelector:@selector(accessibilityActivate)]) { + LOGI(@"long_press_xy(sim): fallback to accessibilityActivate on %@", + NSStringFromClass(object_getClass(elem))); + [elem accessibilityActivate]; + fired = YES; + } + } }); - if (fired) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_long_press_recognizer")); + if (fired) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_long_press_recognizer")); #else // Real device: Began → hold → Ended __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *h = [win hitTest:pt withEvent:nil]; - if (h) { targetWindow = win; hitView = h; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *h = [win hitTest:pt withEvent:nil]; + if (h) { + targetWindow = win; + hitView = h; + return; + } + } + } }); if (!hitView) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); }); [NSThread sleepForTimeInterval:(double)duration_ms / 1000.0]; dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } - // ─── type_text/1 — type into whatever UITextField/UITextView has focus ──────── // // Finds the current first responder in the view hierarchy and calls insertText: @@ -4460,43 +4899,44 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size - encoding:NSUTF8StringEncoding]; + encoding:NSUTF8StringEncoding]; if (!text) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "invalid_utf8")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "invalid_utf8")); __block BOOL typed = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - if ([fr respondsToSelector:@selector(insertText:)]) { - LOGI(@"type_text: inserting %lu chars into %@", - (unsigned long)text.length, NSStringFromClass([fr class])); - [fr insertText:text]; - typed = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + if ([fr respondsToSelector:@selector(insertText:)]) { + LOGI(@"type_text: inserting %lu chars into %@", (unsigned long)text.length, + NSStringFromClass([fr class])); + [fr insertText:text]; + typed = YES; + } + return; + } + } }); - if (typed) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (typed) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── swipe_xy/4 — scroll gesture from (x1,y1) to (x2,y2) ──────────────────── // // Simulator: walks the hit-test chain up from the touch point to find a @@ -4509,9 +4949,11 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a static UIScrollView *find_scroll_view_at(CGPoint pt) { for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; + if (win.isHidden) + continue; UIView *hit = [win hitTest:pt withEvent:nil]; UIView *v = hit; while (v) { @@ -4526,10 +4968,8 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double x1, y1, x2, y2; - if (!enif_get_double(env, argv[0], &x1) || - !enif_get_double(env, argv[1], &y1) || - !enif_get_double(env, argv[2], &x2) || - !enif_get_double(env, argv[3], &y2)) + if (!enif_get_double(env, argv[0], &x1) || !enif_get_double(env, argv[1], &y1) || + !enif_get_double(env, argv[2], &x2) || !enif_get_double(env, argv[3], &y2)) return enif_make_badarg(env); CGFloat dx = (CGFloat)(x2 - x1); @@ -4540,70 +4980,74 @@ static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar #if TARGET_OS_SIMULATOR __block BOOL scrolled = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - UIScrollView *sv = find_scroll_view_at(mid); - if (!sv) { - // Also try start point - sv = find_scroll_view_at(CGPointMake((CGFloat)x1, (CGFloat)y1)); - } - if (!sv) return; - - CGPoint cur = sv.contentOffset; - // Swiping up (dy < 0) means content moves down (contentOffset.y increases) - CGFloat newX = cur.x - dx; - CGFloat newY = cur.y - dy; - // Clamp to valid range - CGFloat maxX = MAX(0.0f, sv.contentSize.width - sv.bounds.size.width); - CGFloat maxY = MAX(0.0f, sv.contentSize.height - sv.bounds.size.height); - newX = MAX(0.0f, MIN(newX, maxX)); - newY = MAX(0.0f, MIN(newY, maxY)); - LOGI(@"swipe_xy(sim): sv=%@ offset (%.1f,%.1f) → (%.1f,%.1f)", - NSStringFromClass([sv class]), cur.x, cur.y, newX, newY); - [sv setContentOffset:CGPointMake(newX, newY) animated:YES]; - scrolled = YES; + UIScrollView *sv = find_scroll_view_at(mid); + if (!sv) { + // Also try start point + sv = find_scroll_view_at(CGPointMake((CGFloat)x1, (CGFloat)y1)); + } + if (!sv) + return; + + CGPoint cur = sv.contentOffset; + // Swiping up (dy < 0) means content moves down (contentOffset.y increases) + CGFloat newX = cur.x - dx; + CGFloat newY = cur.y - dy; + // Clamp to valid range + CGFloat maxX = MAX(0.0f, sv.contentSize.width - sv.bounds.size.width); + CGFloat maxY = MAX(0.0f, sv.contentSize.height - sv.bounds.size.height); + newX = MAX(0.0f, MIN(newX, maxX)); + newY = MAX(0.0f, MIN(newY, maxY)); + LOGI(@"swipe_xy(sim): sv=%@ offset (%.1f,%.1f) → (%.1f,%.1f)", NSStringFromClass([sv class]), + cur.x, cur.y, newX, newY); + [sv setContentOffset:CGPointMake(newX, newY) animated:YES]; + scrolled = YES; }); - if (scrolled) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_scroll_view")); + if (scrolled) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_scroll_view")); #else // Real device: emit Began → 10 Moved steps → Ended via HID events __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; CGPoint startPt = CGPointMake((CGFloat)x1, (CGFloat)y1); - CGPoint endPt = CGPointMake((CGFloat)x2, (CGFloat)y2); + CGPoint endPt = CGPointMake((CGFloat)x2, (CGFloat)y2); dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *hit = [win hitTest:startPt withEvent:nil]; - if (hit) { targetWindow = win; hitView = hit; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *hit = [win hitTest:startPt withEvent:nil]; + if (hit) { + targetWindow = win; + hitView = hit; + return; + } + } + } }); if (!hitView) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); // Began dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, startPt, UITouchPhaseBegan); + mob_send_touch_phase(targetWindow, hitView, startPt, UITouchPhaseBegan); }); // 10 evenly-spaced Moved steps int steps = 10; for (int i = 1; i <= steps; i++) { [NSThread sleepForTimeInterval:0.016]; // ~60fps - CGPoint movePt = CGPointMake( - (CGFloat)(x1 + dx * i / steps), - (CGFloat)(y1 + dy * i / steps) - ); + CGPoint movePt = + CGPointMake((CGFloat)(x1 + dx * i / steps), (CGFloat)(y1 + dy * i / steps)); dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, movePt, UITouchPhaseMoved); + mob_send_touch_phase(targetWindow, hitView, movePt, UITouchPhaseMoved); }); } @@ -4611,101 +5055,129 @@ static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // Ended dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, endPt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, endPt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } -#endif // !MOB_RELEASE — end of test harness block (started near line 2780) - +#endif // !MOB_RELEASE — end of test harness block (started near line 2780) // ── Storage ─────────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_storage_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char loc[32]; enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); - NSString* path = nil; - NSFileManager* fm = [NSFileManager defaultManager]; + NSString *path = nil; + NSFileManager *fm = [NSFileManager defaultManager]; if (strcmp(loc, "temp") == 0) { path = NSTemporaryDirectory(); } else if (strcmp(loc, "documents") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) + firstObject]; } else if (strcmp(loc, "cache") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) + firstObject]; } else if (strcmp(loc, "app_support") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, + YES) firstObject]; [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; } else if (strcmp(loc, "icloud") == 0) { - NSURL* url = [fm URLForUbiquityContainerIdentifier:nil]; + NSURL *url = [fm URLForUbiquityContainerIdentifier:nil]; if (url) { path = [url URLByAppendingPathComponent:@"Documents"].path; - [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; + [fm createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; } } - if (!path) return enif_make_atom(env, "nil"); - const char* cpath = path.UTF8String; - ErlNifBinary bin; enif_alloc_binary(strlen(cpath), &bin); + if (!path) + return enif_make_atom(env, "nil"); + const char *cpath = path.UTF8String; + ErlNifBinary bin; + enif_alloc_binary(strlen(cpath), &bin); memcpy(bin.data, cpath, strlen(cpath)); return enif_make_binary(env, &bin); } -static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* path = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); - - [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly - handler:^(PHAuthorizationStatus status) { - if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple4(e, - enif_make_atom(e, "storage"), enif_make_atom(e, "error"), - enif_make_atom(e, "save_to_library"), enif_make_atom(e, "permission_denied")); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); - return; - } - [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ - NSURL* url = [NSURL fileURLWithPath:path]; - NSString* ext = path.pathExtension.lowercaseString; - BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext]; - if (isVideo) [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:url]; - else [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:url]; - } completionHandler:^(BOOL success, NSError* err) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (success) { - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); - memcpy(pb.data, cpath, strlen(cpath)); - msg = enif_make_tuple3(e, - enif_make_atom(e, "storage"), - enif_make_atom(e, "saved_to_library"), - enif_make_binary(e, &pb)); - } else { - msg = enif_make_tuple4(e, - enif_make_atom(e, "storage"), enif_make_atom(e, "error"), - enif_make_atom(e, "save_to_library"), enif_make_atom(e, "save_failed")); - } - enif_send(NULL, &pid, e, msg); - enif_free_env(e); - }]; - }]; + NSString *path = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + ErlNifPid pid; + enif_self(env, &pid); + + [PHPhotoLibrary + requestAuthorizationForAccessLevel:PHAccessLevelAddOnly + handler:^(PHAuthorizationStatus status) { + if (status != PHAuthorizationStatusAuthorized && + status != PHAuthorizationStatusLimited) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple4( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "error"), + enif_make_atom(e, "save_to_library"), + enif_make_atom(e, "permission_denied")); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + return; + } + [[PHPhotoLibrary sharedPhotoLibrary] + performChanges:^{ + NSURL *url = [NSURL fileURLWithPath:path]; + NSString *ext = path.pathExtension.lowercaseString; + BOOL isVideo = + [@[ @"mp4", @"mov", @"m4v" ] containsObject:ext]; + if (isVideo) + [PHAssetChangeRequest + creationRequestForAssetFromVideoAtFileURL:url]; + else + [PHAssetChangeRequest + creationRequestForAssetFromImageAtFileURL:url]; + } + completionHandler:^(BOOL success, NSError *err) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg; + if (success) { + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + msg = enif_make_tuple3( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "saved_to_library"), + enif_make_binary(e, &pb)); + } else { + msg = enif_make_tuple4( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "error"), + enif_make_atom(e, "save_to_library"), + enif_make_atom(e, "save_failed")); + } + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + }]; + }]; return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_supported")); +static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported")); } -static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "nil"); } @@ -4713,56 +5185,54 @@ static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, con // g_webview is set by MobWebView (MobRootView.swift) when the component is created. // mob_deliver_webview_message / _blocked are called from Swift (via bridging header). -static void deliver_webview_binary(const char* tag, const char* utf8) { - ErlNifEnv* env = enif_alloc_env(); +static void deliver_webview_binary(const char *tag, const char *utf8) { + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (!enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - enif_free_env(env); return; + enif_free_env(env); + return; } size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "webview"), - enif_make_atom(env, tag), - enif_make_binary(env, &bin)); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "webview"), + enif_make_atom(env, tag), enif_make_binary(env, &bin)); enif_send(NULL, &pid, env, msg); enif_free_env(env); } -void mob_deliver_webview_message(const char* json_utf8) { +void mob_deliver_webview_message(const char *json_utf8) { deliver_webview_binary("message", json_utf8); } -void mob_deliver_webview_blocked(const char* url_utf8) { +void mob_deliver_webview_blocked(const char *url_utf8) { deliver_webview_binary("blocked", url_utf8); } -WKWebView* g_webview = nil; - +WKWebView *g_webview = nil; // ── Alert delivery (called from UIAlertAction blocks) ──────────────────────── -static void mob_deliver_alert_action(const char* action) { - ErlNifEnv* env = enif_alloc_env(); +static void mob_deliver_alert_action(const char *action) { + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "alert"), - enif_make_atom(env, action)); + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "alert"), enif_make_atom(env, action)); enif_send(NULL, &pid, env, msg); } enif_free_env(env); } // Returns the root UIViewController for presenting dialogs. -static UIViewController* root_vc(void) { - for (UIWindowScene* scene in [UIApplication sharedApplication].connectedScenes) { +static UIViewController *root_vc(void) { + for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { if (scene.activationState == UISceneActivationStateForegroundActive) { - UIWindow* win = scene.windows.firstObject; - UIViewController* vc = win.rootViewController; - while (vc.presentedViewController) vc = vc.presentedViewController; + UIWindow *win = scene.windows.firstObject; + UIViewController *vc = win.rootViewController; + while (vc.presentedViewController) + vc = vc.presentedViewController; return vc; } } @@ -4771,184 +5241,229 @@ static void mob_deliver_alert_action(const char* action) { // ── NIF: alert_show/3 ──────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_alert_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, msg_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[2], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) + return enif_make_badarg(env); - NSString* title = [[NSString alloc] initWithBytes:title_bin.data length:title_bin.size encoding:NSUTF8StringEncoding]; - NSString* message = msg_bin.size > 0 ? [[NSString alloc] initWithBytes:msg_bin.data length:msg_bin.size encoding:NSUTF8StringEncoding] : nil; - NSData* btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; + NSString *title = [[NSString alloc] initWithBytes:title_bin.data + length:title_bin.size + encoding:NSUTF8StringEncoding]; + NSString *message = msg_bin.size > 0 ? [[NSString alloc] initWithBytes:msg_bin.data + length:msg_bin.size + encoding:NSUTF8StringEncoding] + : nil; + NSData *btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; dispatch_async(dispatch_get_main_queue(), ^{ - NSArray* buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; - if (![buttons isKindOfClass:[NSArray class]]) return; - - UIAlertController* ac = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - for (NSDictionary* btn in buttons) { - NSString* label = btn[@"label"] ?: @""; - NSString* action = btn[@"action"] ?: @"dismiss"; - NSString* style = btn[@"style"] ?: @"default"; - UIAlertActionStyle as = UIAlertActionStyleDefault; - if ([style isEqualToString:@"cancel"]) as = UIAlertActionStyleCancel; - if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char* act_c = [action UTF8String]; - [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction* _) { - mob_deliver_alert_action(act_c); - }]]; - } - UIViewController* vc = root_vc(); - if (vc) [vc presentViewController:ac animated:YES completion:nil]; + NSArray *buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; + if (![buttons isKindOfClass:[NSArray class]]) + return; + + UIAlertController *ac = + [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + for (NSDictionary *btn in buttons) { + NSString *label = btn[@"label"] ?: @""; + NSString *action = btn[@"action"] ?: @"dismiss"; + NSString *style = btn[@"style"] ?: @"default"; + UIAlertActionStyle as = UIAlertActionStyleDefault; + if ([style isEqualToString:@"cancel"]) + as = UIAlertActionStyleCancel; + if ([style isEqualToString:@"destructive"]) + as = UIAlertActionStyleDestructive; + const char *act_c = [action UTF8String]; + [ac addAction:[UIAlertAction actionWithTitle:label + style:as + handler:^(UIAlertAction *_) { + mob_deliver_alert_action(act_c); + }]]; + } + UIViewController *vc = root_vc(); + if (vc) + [vc presentViewController:ac animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } // ── NIF: action_sheet_show/2 ───────────────────────────────────────────────── -static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) + return enif_make_badarg(env); - NSString* title = title_bin.size > 0 ? [[NSString alloc] initWithBytes:title_bin.data length:title_bin.size encoding:NSUTF8StringEncoding] : nil; - NSData* btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; + NSString *title = title_bin.size > 0 ? [[NSString alloc] initWithBytes:title_bin.data + length:title_bin.size + encoding:NSUTF8StringEncoding] + : nil; + NSData *btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; dispatch_async(dispatch_get_main_queue(), ^{ - NSArray* buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; - if (![buttons isKindOfClass:[NSArray class]]) return; - - UIAlertController* ac = [UIAlertController alertControllerWithTitle:title - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; - for (NSDictionary* btn in buttons) { - NSString* label = btn[@"label"] ?: @""; - NSString* action = btn[@"action"] ?: @"dismiss"; - NSString* style = btn[@"style"] ?: @"default"; - UIAlertActionStyle as = UIAlertActionStyleDefault; - if ([style isEqualToString:@"cancel"]) as = UIAlertActionStyleCancel; - if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char* act_c = [action UTF8String]; - [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction* _) { - mob_deliver_alert_action(act_c); - }]]; - } - UIViewController* vc = root_vc(); - if (!vc) return; - // iPad requires a source view for action sheets - if (ac.popoverPresentationController) { - ac.popoverPresentationController.sourceView = vc.view; - ac.popoverPresentationController.sourceRect = - CGRectMake(vc.view.bounds.size.width / 2, vc.view.bounds.size.height, 0, 0); - } - [vc presentViewController:ac animated:YES completion:nil]; + NSArray *buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; + if (![buttons isKindOfClass:[NSArray class]]) + return; + + UIAlertController *ac = + [UIAlertController alertControllerWithTitle:title + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + for (NSDictionary *btn in buttons) { + NSString *label = btn[@"label"] ?: @""; + NSString *action = btn[@"action"] ?: @"dismiss"; + NSString *style = btn[@"style"] ?: @"default"; + UIAlertActionStyle as = UIAlertActionStyleDefault; + if ([style isEqualToString:@"cancel"]) + as = UIAlertActionStyleCancel; + if ([style isEqualToString:@"destructive"]) + as = UIAlertActionStyleDestructive; + const char *act_c = [action UTF8String]; + [ac addAction:[UIAlertAction actionWithTitle:label + style:as + handler:^(UIAlertAction *_) { + mob_deliver_alert_action(act_c); + }]]; + } + UIViewController *vc = root_vc(); + if (!vc) + return; + // iPad requires a source view for action sheets + if (ac.popoverPresentationController) { + ac.popoverPresentationController.sourceView = vc.view; + ac.popoverPresentationController.sourceRect = + CGRectMake(vc.view.bounds.size.width / 2, vc.view.bounds.size.height, 0, 0); + } + [vc presentViewController:ac animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } // ── NIF: toast_show/2 ──────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_toast_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_toast_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary msg_bin; char dur[8] = "short"; if (!enif_inspect_binary(env, argv[0], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) + return enif_make_badarg(env); enif_get_atom(env, argv[1], dur, sizeof(dur), ERL_NIF_LATIN1); - NSString* message = [[NSString alloc] initWithBytes:msg_bin.data length:msg_bin.size encoding:NSUTF8StringEncoding]; + NSString *message = [[NSString alloc] initWithBytes:msg_bin.data + length:msg_bin.size + encoding:NSUTF8StringEncoding]; double seconds = strcmp(dur, "long") == 0 ? 3.5 : 2.0; dispatch_async(dispatch_get_main_queue(), ^{ - // Find the key window - UIWindow* window = nil; - for (UIWindowScene* scene in [UIApplication sharedApplication].connectedScenes) { - if (scene.activationState == UISceneActivationStateForegroundActive) { - window = scene.windows.firstObject; break; - } - } - if (!window) return; - - UILabel* label = [[UILabel alloc] init]; - label.text = message; - label.textColor = [UIColor whiteColor]; - label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.75]; - label.textAlignment = NSTextAlignmentCenter; - label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; - label.layer.cornerRadius = 12; - label.layer.masksToBounds = YES; - label.numberOfLines = 0; - - CGFloat maxW = window.bounds.size.width - 48; - CGSize fit = [label sizeThatFits:CGSizeMake(maxW - 32, 200)]; - CGFloat w = MIN(fit.width + 32, maxW); - CGFloat h = fit.height + 16; - CGFloat x = (window.bounds.size.width - w) / 2; - CGFloat y = window.bounds.size.height - h - 80; // above home indicator - label.frame = CGRectMake(x, y, w, h); - label.alpha = 0; - - [window addSubview:label]; - [UIView animateWithDuration:0.25 animations:^{ label.alpha = 1.0; } completion:^(BOOL _) { + // Find the key window + UIWindow *window = nil; + for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive) { + window = scene.windows.firstObject; + break; + } + } + if (!window) + return; + + UILabel *label = [[UILabel alloc] init]; + label.text = message; + label.textColor = [UIColor whiteColor]; + label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.75]; + label.textAlignment = NSTextAlignmentCenter; + label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + label.layer.cornerRadius = 12; + label.layer.masksToBounds = YES; + label.numberOfLines = 0; + + CGFloat maxW = window.bounds.size.width - 48; + CGSize fit = [label sizeThatFits:CGSizeMake(maxW - 32, 200)]; + CGFloat w = MIN(fit.width + 32, maxW); + CGFloat h = fit.height + 16; + CGFloat x = (window.bounds.size.width - w) / 2; + CGFloat y = window.bounds.size.height - h - 80; // above home indicator + label.frame = CGRectMake(x, y, w, h); + label.alpha = 0; + + [window addSubview:label]; + [UIView animateWithDuration:0.25 + animations:^{ + label.alpha = 1.0; + } + completion:^(BOOL _) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(seconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [UIView animateWithDuration:0.25 animations:^{ label.alpha = 0; } - completion:^(BOOL _) { [label removeFromSuperview]; }]; - }); - }]; + [UIView animateWithDuration:0.25 + animations:^{ + label.alpha = 0; + } + completion:^(BOOL _) { + [label removeFromSuperview]; + }]; + }); + }]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* code = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; + NSString *code = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:code completionHandler:nil]; + [g_webview evaluateJavaScript:code completionHandler:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* json = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; + NSString *json = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; // Escape for single-quoted JS string: backslash then apostrophe - NSString* escaped = [json stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; + NSString *escaped = [json stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; escaped = [escaped stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; - NSString* js = [NSString stringWithFormat:@"window.mob&&window.mob._dispatch('%@')", escaped]; + NSString *js = [NSString stringWithFormat:@"window.mob&&window.mob._dispatch('%@')", escaped]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:js completionHandler:nil]; + [g_webview evaluateJavaScript:js completionHandler:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // dispatch_sync blocks this BEAM scheduler thread until the main queue drains. // Intentional — the caller (Mob.Screen back handler) needs the boolean before deciding // whether to pop the nav stack. Same pattern as clipboard_get and safe_area. // The main thread is expected to be idle during a back gesture. __block BOOL result = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - result = g_webview ? [g_webview canGoBack] : NO; + result = g_webview ? [g_webview canGoBack] : NO; }); return enif_make_atom(env, result ? "true" : "false"); } -static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview goBack]; + [g_webview goBack]; }); return enif_make_atom(env, "ok"); } @@ -4964,13 +5479,13 @@ static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_ typedef struct { ErlNifPid pid; - int active; + int active; } ComponentHandle; static ComponentHandle component_handles[MAX_COMPONENT_HANDLES]; -static ErlNifMutex* component_mutex = NULL; +static ErlNifMutex *component_mutex = NULL; -static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); @@ -4978,7 +5493,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N enif_mutex_lock(component_mutex); for (int i = 0; i < MAX_COMPONENT_HANDLES; i++) { if (!component_handles[i].active) { - component_handles[i].pid = pid; + component_handles[i].pid = pid; component_handles[i].active = 1; enif_mutex_unlock(component_mutex); return enif_make_int(env, i); @@ -4988,7 +5503,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N return enif_make_badarg(env); } -static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { int handle; if (!enif_get_int(env, argv[0], &handle) || handle < 0 || handle >= MAX_COMPONENT_HANDLES) return enif_make_badarg(env); @@ -4999,8 +5514,9 @@ static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL return enif_make_atom(env, "ok"); } -void mob_send_component_event(int handle, const char* event, const char* payload_json) { - if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) return; +void mob_send_component_event(int handle, const char *event, const char *payload_json) { + if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) + return; enif_mutex_lock(component_mutex); if (!component_handles[handle].active) { @@ -5010,11 +5526,10 @@ void mob_send_component_event(int handle, const char* event, const char* payload ErlNifPid pid = component_handles[handle].pid; enif_mutex_unlock(component_mutex); - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "component_event"), - enif_make_string(env, event, ERL_NIF_LATIN1), - enif_make_string(env, payload_json, ERL_NIF_LATIN1)); + ErlNifEnv *env = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "component_event"), + enif_make_string(env, event, ERL_NIF_LATIN1), + enif_make_string(env, payload_json, ERL_NIF_LATIN1)); enif_send(NULL, &pid, env, msg); enif_free_env(env); } @@ -5046,94 +5561,103 @@ void mob_send_component_event(int handle, const char* event, const char* payload // :nif_error when these aren't loaded, which is the right thing for // shipped apps (the harness uses private UIKit APIs and Apple's // App Store validator rejects binaries that reference them). - {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"screen_info", 0, nif_screen_info, 0}, - {"tap", 1, nif_tap, 0}, - {"ax_action", 2, nif_ax_action, 0}, - {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, - {"tap_xy", 2, nif_tap_xy, 0}, - {"type_text", 1, nif_type_text, 0}, - {"delete_backward", 0, nif_delete_backward, 0}, - {"key_press", 1, nif_key_press, 0}, - {"clear_text", 0, nif_clear_text, 0}, - {"long_press_xy", 3, nif_long_press_xy, 0}, - {"swipe_xy", 4, nif_swipe_xy, 0}, + {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"screen_info", 0, nif_screen_info, 0}, + {"tap", 1, nif_tap, 0}, + {"ax_action", 2, nif_ax_action, 0}, + {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, + {"tap_xy", 2, nif_tap_xy, 0}, + {"type_text", 1, nif_type_text, 0}, + {"delete_backward", 0, nif_delete_backward, 0}, + {"key_press", 1, nif_key_press, 0}, + {"clear_text", 0, nif_clear_text, 0}, + {"long_press_xy", 3, nif_long_press_xy, 0}, + {"swipe_xy", 4, nif_swipe_xy, 0}, #endif // ── Core mob functions ─────────────────────────────────────────────────── {"background_keep_alive", 0, nif_background_keep_alive, 0}, - {"background_stop", 0, nif_background_stop, 0}, - {"battery_level", 0, nif_battery_level, 0}, + {"background_stop", 0, nif_background_stop, 0}, + {"battery_level", 0, nif_battery_level, 0}, // ── Mob.Device — lifecycle events + queries ────────────────────────────── {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, - {"device_battery_state", 0, nif_device_battery_state, 0}, - {"device_thermal_state", 0, nif_device_thermal_state, 0}, + {"device_battery_state", 0, nif_device_battery_state, 0}, + {"device_thermal_state", 0, nif_device_thermal_state, 0}, {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, - {"device_foreground", 0, nif_device_foreground, 0}, - {"device_os_version", 0, nif_device_os_version, 0}, - {"device_model", 0, nif_device_model, 0}, - {"platform", 0, nif_platform, 0}, - {"color_scheme", 0, nif_color_scheme, 0}, - {"log", 1, nif_log, 0}, - {"log", 2, nif_log2, 0}, + {"device_foreground", 0, nif_device_foreground, 0}, + {"device_os_version", 0, nif_device_os_version, 0}, + {"device_model", 0, nif_device_model, 0}, + {"platform", 0, nif_platform, 0}, + {"color_scheme", 0, nif_color_scheme, 0}, + {"log", 1, nif_log, 0}, + {"log", 2, nif_log2, 0}, {"set_transition", 1, nif_set_transition, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"register_tap", 1, nif_register_tap, 0}, - {"clear_taps", 0, nif_clear_taps, 0}, - {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, - {"haptic", 1, nif_haptic, 0}, - {"clipboard_put", 1, nif_clipboard_put, 0}, - {"clipboard_get", 0, nif_clipboard_get, 0}, - {"share_text", 1, nif_share_text, 0}, - {"open_url", 1, nif_open_url, 0}, - {"request_permission", 1, nif_request_permission, 0}, - {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, - {"location_get_once", 0, nif_location_get_once, 0}, - {"location_start", 1, nif_location_start, 0}, - {"location_stop", 0, nif_location_stop, 0}, - {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, - {"camera_capture_video", 1, nif_camera_capture_video, 0}, - {"camera_start_preview", 1, nif_camera_start_preview, 0}, - {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, - {"photos_pick", 2, nif_photos_pick, 0}, - {"files_pick", 1, nif_files_pick, 0}, - {"audio_start_recording", 1, nif_audio_start_recording, 0}, - {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, - {"audio_play", 2, nif_audio_play, 0}, - {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, - {"audio_set_volume", 1, nif_audio_set_volume, 0}, - {"motion_start", 2, nif_motion_start, 0}, - {"motion_stop", 0, nif_motion_stop, 0}, - {"scanner_scan", 1, nif_scanner_scan, 0}, - {"notify_schedule", 1, nif_notify_schedule, 0}, - {"notify_cancel", 1, nif_notify_cancel, 0}, - {"notify_register_push", 0, nif_notify_register_push, 0}, - {"take_launch_notification", 0, nif_take_launch_notification, 0}, - {"storage_dir", 1, nif_storage_dir, 0}, - {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, - {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, - {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, - {"alert_show", 3, nif_alert_show, 0}, - {"action_sheet_show", 2, nif_action_sheet_show, 0}, - {"toast_show", 2, nif_toast_show, 0}, - {"webview_eval_js", 1, nif_webview_eval_js, 0}, - {"webview_post_message",1, nif_webview_post_message,0}, + {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"register_tap", 1, nif_register_tap, 0}, + {"clear_taps", 0, nif_clear_taps, 0}, + {"exit_app", 0, nif_exit_app, 0}, + {"safe_area", 0, nif_safe_area, 0}, + {"haptic", 1, nif_haptic, 0}, + {"clipboard_put", 1, nif_clipboard_put, 0}, + {"clipboard_get", 0, nif_clipboard_get, 0}, + {"share_text", 1, nif_share_text, 0}, + {"open_url", 1, nif_open_url, 0}, + {"request_permission", 1, nif_request_permission, 0}, + {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, + {"location_get_once", 0, nif_location_get_once, 0}, + {"location_start", 1, nif_location_start, 0}, + {"location_stop", 0, nif_location_stop, 0}, + {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, + {"camera_capture_video", 1, nif_camera_capture_video, 0}, + {"camera_start_preview", 1, nif_camera_start_preview, 0}, + {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, + {"photos_pick", 2, nif_photos_pick, 0}, + {"files_pick", 1, nif_files_pick, 0}, + {"audio_start_recording", 1, nif_audio_start_recording, 0}, + {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, + {"audio_play", 2, nif_audio_play, 0}, + {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, + {"audio_set_volume", 1, nif_audio_set_volume, 0}, + {"motion_start", 2, nif_motion_start, 0}, + {"motion_stop", 0, nif_motion_stop, 0}, + {"scanner_scan", 1, nif_scanner_scan, 0}, + {"notify_schedule", 1, nif_notify_schedule, 0}, + {"notify_cancel", 1, nif_notify_cancel, 0}, + {"notify_register_push", 0, nif_notify_register_push, 0}, + {"take_launch_notification", 0, nif_take_launch_notification, 0}, + {"storage_dir", 1, nif_storage_dir, 0}, + {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, + {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, + {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, + {"alert_show", 3, nif_alert_show, 0}, + {"action_sheet_show", 2, nif_action_sheet_show, 0}, + {"toast_show", 2, nif_toast_show, 0}, + {"webview_eval_js", 1, nif_webview_eval_js, 0}, + {"webview_post_message", 1, nif_webview_post_message, 0}, {"webview_can_go_back", 0, nif_webview_can_go_back, 0}, - {"webview_go_back", 0, nif_webview_go_back, 0}, - {"register_component", 1, nif_register_component, 0}, + {"webview_go_back", 0, nif_webview_go_back, 0}, + {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, }; -static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { +static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { LOGI(@"nif_load: initialising mob_nif (iOS/SwiftUI JSON backend)"); tap_mutex = enif_mutex_create("mob_tap_mutex"); - if (!tap_mutex) { LOGE(@"nif_load: failed to create tap mutex"); return -1; } + if (!tap_mutex) { + LOGE(@"nif_load: failed to create tap mutex"); + return -1; + } component_mutex = enif_mutex_create("mob_component_mutex"); - if (!component_mutex) { LOGE(@"nif_load: failed to create component mutex"); return -1; } + if (!component_mutex) { + LOGE(@"nif_load: failed to create component mutex"); + return -1; + } g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); - if (!g_launch_notif_mutex) { LOGE(@"nif_load: failed to create launch notif mutex"); return -1; } + if (!g_launch_notif_mutex) { + LOGE(@"nif_load: failed to create launch notif mutex"); + return -1; + } LOGI(@"nif_load: mob_nif ready"); return 0; } diff --git a/lib/mix/tasks/erlfmt.ex b/lib/mix/tasks/erlfmt.ex new file mode 100644 index 00000000..d96e9abe --- /dev/null +++ b/lib/mix/tasks/erlfmt.ex @@ -0,0 +1,99 @@ +defmodule Mix.Tasks.Erlfmt do + @moduledoc """ + Format `.erl` files (or check formatting with `--check`). + + Wraps `erlfmt`'s library API. Exists because the upstream `erlfmt` Hex package + ships an escript build but no `mix` task; the project's pre-commit checklist + (`CLAUDE.md`) references `mix erlfmt --check src/` so this task makes that + instruction actually work. + + ## Usage + + mix erlfmt --check src/ # exit 0 if clean, exit 1 if any file would change + mix erlfmt --write src/ # rewrite files in place + + Either `--check` or `--write` is required. Paths can be files or directories; + directories are walked for `*.erl` files. + """ + + use Mix.Task + + @shortdoc "Format Erlang sources via erlfmt" + + @impl Mix.Task + def run(args) do + {opts, paths} = + OptionParser.parse!(args, + strict: [check: :boolean, write: :boolean], + aliases: [c: :check, w: :write] + ) + + if !opts[:check] and !opts[:write] do + Mix.raise("mix erlfmt requires --check or --write") + end + + if paths == [], do: Mix.raise("mix erlfmt requires at least one path") + + Application.ensure_all_started(:erlfmt) + + files = Enum.flat_map(paths, &collect_erl_files/1) + + {ok_count, changed} = + Enum.reduce(files, {0, []}, fn file, {ok, changed} -> + case :erlfmt.format_file(String.to_charlist(file), [:return]) do + {:ok, formatted, _warnings} -> + original = File.read!(file) + # erlfmt returns iodata that may include codepoints > 255 (e.g. + # em-dashes inside strings/comments). `:unicode.characters_to_binary` + # handles those; `IO.iodata_to_binary` would crash with ArgumentError. + new = :unicode.characters_to_binary(formatted) + + cond do + new == original -> + {ok + 1, changed} + + opts[:write] -> + File.write!(file, new) + Mix.shell().info("formatted #{file}") + {ok + 1, changed} + + true -> + {ok, [file | changed]} + end + + {:skip, _} -> + {ok + 1, changed} + + {:error, reason} -> + Mix.shell().error("#{file}: #{inspect(reason)}") + {ok, [file | changed]} + end + end) + + cond do + changed == [] -> + Mix.shell().info("erlfmt: #{ok_count} file(s) checked, all formatted") + :ok + + opts[:check] -> + Mix.shell().error( + "erlfmt: #{length(changed)} file(s) need formatting:\n " <> + Enum.join(changed, "\n ") <> + "\n\nRun `mix erlfmt --write ` to fix." + ) + + exit({:shutdown, 1}) + + true -> + :ok + end + end + + defp collect_erl_files(path) do + cond do + File.dir?(path) -> Path.wildcard("#{path}/**/*.erl") + File.regular?(path) and String.ends_with?(path, ".erl") -> [path] + true -> [] + end + end +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index bcfd676c..ba468b7d 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -1,256 +1,261 @@ %% mob_nif.erl — Erlang NIF stub module. %% ERL_NIF_INIT in mob_nif.c / mob_nif.m registers functions under this module name. -module(mob_nif). --export([platform/0, - color_scheme/0, - log/1, log/2, - set_transition/1, - set_root/1, - register_tap/1, - clear_taps/0, - exit_app/0, - safe_area/0, - %% Device utilities (no permission required) - haptic/1, - clipboard_put/1, - clipboard_get/0, - share_text/1, - open_url/1, - %% Permissions - request_permission/1, - %% Biometric - biometric_authenticate/1, - %% Location - location_get_once/0, - location_start/1, - location_stop/0, - %% Camera - camera_capture_photo/1, - camera_capture_video/1, - camera_start_preview/1, - camera_stop_preview/0, - %% Photo library - photos_pick/2, - %% File picker - files_pick/1, - %% Audio recording - audio_start_recording/1, - audio_stop_recording/0, - %% Audio playback - audio_play/2, - audio_stop_playback/0, - audio_set_volume/1, - %% Motion sensors - motion_start/2, - motion_stop/0, - %% QR / barcode scanner - scanner_scan/1, - %% Notifications - notify_schedule/1, - notify_cancel/1, - notify_register_push/0, - take_launch_notification/0, - %% Storage - storage_dir/1, - storage_save_to_photo_library/1, - storage_save_to_media_store/2, - storage_external_files_dir/1, - %% Alerts / overlays - alert_show/3, - action_sheet_show/2, - toast_show/2, - %% WebView - webview_eval_js/1, - webview_post_message/1, - webview_can_go_back/0, - webview_go_back/0, - %% Native view components - register_component/1, - deregister_component/1, - %% Background execution - background_keep_alive/0, - background_stop/0, - %% Device state - battery_level/0, - %% Device lifecycle (Mob.Device) - device_set_dispatcher/1, - device_battery_state/0, - device_thermal_state/0, - device_low_power_mode/0, - device_foreground/0, - device_os_version/0, - device_model/0, - %% Test harness — native UI inspection and interaction - ui_tree/0, - ui_view_tree/0, - ui_debug/0, - screen_info/0, - tap/1, - ax_action/2, - ax_action_at_xy/3, - tap_xy/2, - type_text/1, - delete_backward/0, - key_press/1, - clear_text/0, - long_press_xy/3, - swipe_xy/4]). +-export([ + platform/0, + color_scheme/0, + log/1, log/2, + set_transition/1, + set_root/1, + register_tap/1, + clear_taps/0, + exit_app/0, + safe_area/0, + %% Device utilities (no permission required) + haptic/1, + clipboard_put/1, + clipboard_get/0, + share_text/1, + open_url/1, + %% Permissions + request_permission/1, + %% Biometric + biometric_authenticate/1, + %% Location + location_get_once/0, + location_start/1, + location_stop/0, + %% Camera + camera_capture_photo/1, + camera_capture_video/1, + camera_start_preview/1, + camera_stop_preview/0, + %% Photo library + photos_pick/2, + %% File picker + files_pick/1, + %% Audio recording + audio_start_recording/1, + audio_stop_recording/0, + %% Audio playback + audio_play/2, + audio_stop_playback/0, + audio_set_volume/1, + %% Motion sensors + motion_start/2, + motion_stop/0, + %% QR / barcode scanner + scanner_scan/1, + %% Notifications + notify_schedule/1, + notify_cancel/1, + notify_register_push/0, + take_launch_notification/0, + %% Storage + storage_dir/1, + storage_save_to_photo_library/1, + storage_save_to_media_store/2, + storage_external_files_dir/1, + %% Alerts / overlays + alert_show/3, + action_sheet_show/2, + toast_show/2, + %% WebView + webview_eval_js/1, + webview_post_message/1, + webview_can_go_back/0, + webview_go_back/0, + %% Native view components + register_component/1, + deregister_component/1, + %% Background execution + background_keep_alive/0, + background_stop/0, + %% Device state + battery_level/0, + %% Device lifecycle (Mob.Device) + device_set_dispatcher/1, + device_battery_state/0, + device_thermal_state/0, + device_low_power_mode/0, + device_foreground/0, + device_os_version/0, + device_model/0, + %% Test harness — native UI inspection and interaction + ui_tree/0, + ui_view_tree/0, + ui_debug/0, + screen_info/0, + tap/1, + ax_action/2, + ax_action_at_xy/3, + tap_xy/2, + type_text/1, + delete_backward/0, + key_press/1, + clear_text/0, + long_press_xy/3, + swipe_xy/4 +]). --nifs([platform/0, - color_scheme/0, - log/1, log/2, - set_transition/1, - set_root/1, - register_tap/1, - clear_taps/0, - exit_app/0, - safe_area/0, - haptic/1, - clipboard_put/1, - clipboard_get/0, - share_text/1, - open_url/1, - request_permission/1, - biometric_authenticate/1, - location_get_once/0, - location_start/1, - location_stop/0, - camera_capture_photo/1, - camera_capture_video/1, - camera_start_preview/1, - camera_stop_preview/0, - photos_pick/2, - files_pick/1, - audio_start_recording/1, - audio_stop_recording/0, - audio_play/2, - audio_stop_playback/0, - audio_set_volume/1, - motion_start/2, - motion_stop/0, - scanner_scan/1, - notify_schedule/1, - notify_cancel/1, - notify_register_push/0, - take_launch_notification/0, - background_keep_alive/0, - background_stop/0, - battery_level/0, - device_set_dispatcher/1, - device_battery_state/0, - device_thermal_state/0, - device_low_power_mode/0, - device_foreground/0, - device_os_version/0, - device_model/0, - ui_tree/0, - ui_view_tree/0, - ui_debug/0, - screen_info/0, - tap/1, - ax_action/2, - ax_action_at_xy/3, - tap_xy/2, - type_text/1, - delete_backward/0, - key_press/1, - clear_text/0, - long_press_xy/3, - swipe_xy/4, - %% Storage - storage_dir/1, - storage_save_to_photo_library/1, - storage_save_to_media_store/2, - storage_external_files_dir/1, - %% Alerts / overlays - alert_show/3, - action_sheet_show/2, - toast_show/2, - %% WebView - webview_eval_js/1, - webview_post_message/1, - webview_can_go_back/0, - webview_go_back/0, - %% Native view components - register_component/1, - deregister_component/1]). +-nifs([ + platform/0, + color_scheme/0, + log/1, + log/2, + set_transition/1, + set_root/1, + register_tap/1, + clear_taps/0, + exit_app/0, + safe_area/0, + haptic/1, + clipboard_put/1, + clipboard_get/0, + share_text/1, + open_url/1, + request_permission/1, + biometric_authenticate/1, + location_get_once/0, + location_start/1, + location_stop/0, + camera_capture_photo/1, + camera_capture_video/1, + camera_start_preview/1, + camera_stop_preview/0, + photos_pick/2, + files_pick/1, + audio_start_recording/1, + audio_stop_recording/0, + audio_play/2, + audio_stop_playback/0, + audio_set_volume/1, + motion_start/2, + motion_stop/0, + scanner_scan/1, + notify_schedule/1, + notify_cancel/1, + notify_register_push/0, + take_launch_notification/0, + background_keep_alive/0, + background_stop/0, + battery_level/0, + device_set_dispatcher/1, + device_battery_state/0, + device_thermal_state/0, + device_low_power_mode/0, + device_foreground/0, + device_os_version/0, + device_model/0, + ui_tree/0, + ui_view_tree/0, + ui_debug/0, + screen_info/0, + tap/1, + ax_action/2, + ax_action_at_xy/3, + tap_xy/2, + type_text/1, + delete_backward/0, + key_press/1, + clear_text/0, + long_press_xy/3, + swipe_xy/4, + %% Storage + storage_dir/1, + storage_save_to_photo_library/1, + storage_save_to_media_store/2, + storage_external_files_dir/1, + %% Alerts / overlays + alert_show/3, + action_sheet_show/2, + toast_show/2, + %% WebView + webview_eval_js/1, + webview_post_message/1, + webview_can_go_back/0, + webview_go_back/0, + %% Native view components + register_component/1, + deregister_component/1 +]). -on_load(init/0). init() -> erlang:load_nif("mob_nif", 0). -platform() -> erlang:nif_error(not_loaded). -color_scheme() -> erlang:nif_error(not_loaded). -log(_Msg) -> erlang:nif_error(not_loaded). -log(_Level, _Msg) -> erlang:nif_error(not_loaded). -set_transition(_Trans) -> erlang:nif_error(not_loaded). -set_root(_Json) -> erlang:nif_error(not_loaded). -register_tap(_Pid) -> erlang:nif_error(not_loaded). -clear_taps() -> erlang:nif_error(not_loaded). -exit_app() -> erlang:nif_error(not_loaded). -safe_area() -> erlang:nif_error(not_loaded). -haptic(_Type) -> erlang:nif_error(not_loaded). -clipboard_put(_Text) -> erlang:nif_error(not_loaded). -clipboard_get() -> erlang:nif_error(not_loaded). -share_text(_Text) -> erlang:nif_error(not_loaded). -open_url(_Url) -> erlang:nif_error(not_loaded). -request_permission(_Cap) -> erlang:nif_error(not_loaded). -biometric_authenticate(_Reason) -> erlang:nif_error(not_loaded). -location_get_once() -> erlang:nif_error(not_loaded). -location_start(_Accuracy) -> erlang:nif_error(not_loaded). -location_stop() -> erlang:nif_error(not_loaded). -camera_capture_photo(_Quality) -> erlang:nif_error(not_loaded). -camera_capture_video(_MaxDuration)-> erlang:nif_error(not_loaded). -camera_start_preview(_OptsJson) -> erlang:nif_error(not_loaded). -camera_stop_preview() -> erlang:nif_error(not_loaded). -photos_pick(_Max, _Types) -> erlang:nif_error(not_loaded). -files_pick(_MimeTypes) -> erlang:nif_error(not_loaded). -audio_start_recording(_OptsJson) -> erlang:nif_error(not_loaded). -audio_stop_recording() -> erlang:nif_error(not_loaded). -audio_play(_Path, _OptsJson) -> erlang:nif_error(not_loaded). -audio_stop_playback() -> erlang:nif_error(not_loaded). -audio_set_volume(_Volume) -> erlang:nif_error(not_loaded). +platform() -> erlang:nif_error(not_loaded). +color_scheme() -> erlang:nif_error(not_loaded). +log(_Msg) -> erlang:nif_error(not_loaded). +log(_Level, _Msg) -> erlang:nif_error(not_loaded). +set_transition(_Trans) -> erlang:nif_error(not_loaded). +set_root(_Json) -> erlang:nif_error(not_loaded). +register_tap(_Pid) -> erlang:nif_error(not_loaded). +clear_taps() -> erlang:nif_error(not_loaded). +exit_app() -> erlang:nif_error(not_loaded). +safe_area() -> erlang:nif_error(not_loaded). +haptic(_Type) -> erlang:nif_error(not_loaded). +clipboard_put(_Text) -> erlang:nif_error(not_loaded). +clipboard_get() -> erlang:nif_error(not_loaded). +share_text(_Text) -> erlang:nif_error(not_loaded). +open_url(_Url) -> erlang:nif_error(not_loaded). +request_permission(_Cap) -> erlang:nif_error(not_loaded). +biometric_authenticate(_Reason) -> erlang:nif_error(not_loaded). +location_get_once() -> erlang:nif_error(not_loaded). +location_start(_Accuracy) -> erlang:nif_error(not_loaded). +location_stop() -> erlang:nif_error(not_loaded). +camera_capture_photo(_Quality) -> erlang:nif_error(not_loaded). +camera_capture_video(_MaxDuration) -> erlang:nif_error(not_loaded). +camera_start_preview(_OptsJson) -> erlang:nif_error(not_loaded). +camera_stop_preview() -> erlang:nif_error(not_loaded). +photos_pick(_Max, _Types) -> erlang:nif_error(not_loaded). +files_pick(_MimeTypes) -> erlang:nif_error(not_loaded). +audio_start_recording(_OptsJson) -> erlang:nif_error(not_loaded). +audio_stop_recording() -> erlang:nif_error(not_loaded). +audio_play(_Path, _OptsJson) -> erlang:nif_error(not_loaded). +audio_stop_playback() -> erlang:nif_error(not_loaded). +audio_set_volume(_Volume) -> erlang:nif_error(not_loaded). motion_start(_Sensors, _Interval) -> erlang:nif_error(not_loaded). -motion_stop() -> erlang:nif_error(not_loaded). -scanner_scan(_FormatsJson) -> erlang:nif_error(not_loaded). -notify_schedule(_OptsJson) -> erlang:nif_error(not_loaded). -notify_cancel(_Id) -> erlang:nif_error(not_loaded). -notify_register_push() -> erlang:nif_error(not_loaded). -take_launch_notification() -> erlang:nif_error(not_loaded). -background_keep_alive() -> erlang:nif_error(not_loaded). -background_stop() -> erlang:nif_error(not_loaded). -battery_level() -> erlang:nif_error(not_loaded). -device_set_dispatcher(_Pid) -> erlang:nif_error(not_loaded). -device_battery_state() -> erlang:nif_error(not_loaded). -device_thermal_state() -> erlang:nif_error(not_loaded). -device_low_power_mode() -> erlang:nif_error(not_loaded). -device_foreground() -> erlang:nif_error(not_loaded). -device_os_version() -> erlang:nif_error(not_loaded). -device_model() -> erlang:nif_error(not_loaded). -ui_tree() -> erlang:nif_error(not_loaded). -ui_view_tree() -> erlang:nif_error(not_loaded). -ui_debug() -> erlang:nif_error(not_loaded). -screen_info() -> erlang:nif_error(not_loaded). -tap(_Label) -> erlang:nif_error(not_loaded). -ax_action(_Match, _Action) -> erlang:nif_error(not_loaded). -ax_action_at_xy(_X, _Y, _Action) -> erlang:nif_error(not_loaded). -tap_xy(_X, _Y) -> erlang:nif_error(not_loaded). -type_text(_Text) -> erlang:nif_error(not_loaded). -delete_backward() -> erlang:nif_error(not_loaded). -key_press(_Key) -> erlang:nif_error(not_loaded). -clear_text() -> erlang:nif_error(not_loaded). -long_press_xy(_X, _Y, _Ms) -> erlang:nif_error(not_loaded). -swipe_xy(_X1, _Y1, _X2, _Y2) -> erlang:nif_error(not_loaded). -storage_dir(_Location) -> erlang:nif_error(not_loaded). -storage_save_to_photo_library(_Path) -> erlang:nif_error(not_loaded). -storage_save_to_media_store(_Path, _Type) -> erlang:nif_error(not_loaded). -storage_external_files_dir(_Type) -> erlang:nif_error(not_loaded). -alert_show(_Title, _Message, _ButtonsJson) -> erlang:nif_error(not_loaded). -action_sheet_show(_Title, _ButtonsJson) -> erlang:nif_error(not_loaded). -toast_show(_Message, _Duration) -> erlang:nif_error(not_loaded). -webview_eval_js(_Code) -> erlang:nif_error(not_loaded). -webview_post_message(_Json) -> erlang:nif_error(not_loaded). -webview_can_go_back() -> erlang:nif_error(not_loaded). -webview_go_back() -> erlang:nif_error(not_loaded). -register_component(_Pid) -> erlang:nif_error(not_loaded). -deregister_component(_Handle) -> erlang:nif_error(not_loaded). +motion_stop() -> erlang:nif_error(not_loaded). +scanner_scan(_FormatsJson) -> erlang:nif_error(not_loaded). +notify_schedule(_OptsJson) -> erlang:nif_error(not_loaded). +notify_cancel(_Id) -> erlang:nif_error(not_loaded). +notify_register_push() -> erlang:nif_error(not_loaded). +take_launch_notification() -> erlang:nif_error(not_loaded). +background_keep_alive() -> erlang:nif_error(not_loaded). +background_stop() -> erlang:nif_error(not_loaded). +battery_level() -> erlang:nif_error(not_loaded). +device_set_dispatcher(_Pid) -> erlang:nif_error(not_loaded). +device_battery_state() -> erlang:nif_error(not_loaded). +device_thermal_state() -> erlang:nif_error(not_loaded). +device_low_power_mode() -> erlang:nif_error(not_loaded). +device_foreground() -> erlang:nif_error(not_loaded). +device_os_version() -> erlang:nif_error(not_loaded). +device_model() -> erlang:nif_error(not_loaded). +ui_tree() -> erlang:nif_error(not_loaded). +ui_view_tree() -> erlang:nif_error(not_loaded). +ui_debug() -> erlang:nif_error(not_loaded). +screen_info() -> erlang:nif_error(not_loaded). +tap(_Label) -> erlang:nif_error(not_loaded). +ax_action(_Match, _Action) -> erlang:nif_error(not_loaded). +ax_action_at_xy(_X, _Y, _Action) -> erlang:nif_error(not_loaded). +tap_xy(_X, _Y) -> erlang:nif_error(not_loaded). +type_text(_Text) -> erlang:nif_error(not_loaded). +delete_backward() -> erlang:nif_error(not_loaded). +key_press(_Key) -> erlang:nif_error(not_loaded). +clear_text() -> erlang:nif_error(not_loaded). +long_press_xy(_X, _Y, _Ms) -> erlang:nif_error(not_loaded). +swipe_xy(_X1, _Y1, _X2, _Y2) -> erlang:nif_error(not_loaded). +storage_dir(_Location) -> erlang:nif_error(not_loaded). +storage_save_to_photo_library(_Path) -> erlang:nif_error(not_loaded). +storage_save_to_media_store(_Path, _Type) -> erlang:nif_error(not_loaded). +storage_external_files_dir(_Type) -> erlang:nif_error(not_loaded). +alert_show(_Title, _Message, _ButtonsJson) -> erlang:nif_error(not_loaded). +action_sheet_show(_Title, _ButtonsJson) -> erlang:nif_error(not_loaded). +toast_show(_Message, _Duration) -> erlang:nif_error(not_loaded). +webview_eval_js(_Code) -> erlang:nif_error(not_loaded). +webview_post_message(_Json) -> erlang:nif_error(not_loaded). +webview_can_go_back() -> erlang:nif_error(not_loaded). +webview_go_back() -> erlang:nif_error(not_loaded). +register_component(_Pid) -> erlang:nif_error(not_loaded). +deregister_component(_Handle) -> erlang:nif_error(not_loaded). From 3c73d59a914c873b9b99f1e711707974f46abaeb Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:30:08 -0600 Subject: [PATCH 006/254] issues: mark #1, #2, #4, #5 fixed (mob_new + mob_dev companion commits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small lost-in-the-shuffle items closed in this batch. All four were held up by Phase 2 work touching the same files (#1/#2/#4 in live_view_patcher.ex; #5 in native_build.ex). #1 — Phoenix LiveReload mac_listener warnings: code_reloader/watchers/ live_reload disabled in on-device endpoint config. #2 — esbuild/tailwind version-not-configured warnings: versions set via Application.put_env in mob_app.ex before ensure_all_started. #4 — port 4200 collisions across multiple Mob LV apps: per-app hash into 4200..4999 via :erlang.phash2(:, 800). #5 — deploy auto-pick of iPhone over sim was silent: prints the --device alternative when both are connected. #3 (WS→longpoll fallback in WKWebView) is investigation, not a fix — deferred. #6-#11 are larger work (OTP rebuild, AX modifiers, Compose semantics walker, Android 17 SELinux patch). #12, #13 already fixed earlier. #14 is moderate — sim node naming reconciliation between mob_dev's connect.ex and mob_beam.m, deferred. --- issues.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/issues.md b/issues.md index d7418882..e1b9e1ab 100644 --- a/issues.md +++ b/issues.md @@ -4,7 +4,13 @@ Tracked items not yet addressed. Each section captures the symptom, why it happens, and what a fix would look like — so the next session can pick one up without re-deriving context. -## 1. Disable `phoenix_live_reload` on iOS device builds +## 1. Disable `phoenix_live_reload` on iOS device builds — **FIXED 2026-05-10** + +> **Resolution.** `mob_new`'s `mob_live_app_content/4` (LiveViewPatcher) +> now sets `code_reloader: false`, `watchers: []`, `live_reload: false` +> in the on-device endpoint config. Newly-generated LV projects pick this +> up automatically; existing projects need a one-line edit in their +> `mob_app.ex`. **Symptom** — `beam_stdout.log` on launch: ``` @@ -37,7 +43,12 @@ when running on-device (it's a dev-only dep anyway). --- -## 2. Silence `:esbuild` / `:tailwind` startup warnings on-device +## 2. Silence `:esbuild` / `:tailwind` startup warnings on-device — **FIXED 2026-05-10** + +> **Resolution.** Option (a) — `Application.put_env(:esbuild, :version, "0.25.0")` +> + `:tailwind, :version, "3.4.6"` set in `mob_app.ex` before +> `ensure_all_started`. Versions match Phoenix 1.7's defaults; bump +> alongside `mix phx.new` upgrades. **Symptom** — same log: ``` @@ -125,7 +136,23 @@ later (intermittent disconnects under load). --- -## 4. LiveView port 4200 collides across multiple installed Mob LV apps +## 4. LiveView port 4200 collides across multiple installed Mob LV apps — **FIXED 2026-05-10** + +> **Resolution.** Recommendation #1 from below: hash the app name into +> `4200..4999` for the on-device default. Implementation lives in +> `mob_new`'s `mob_live_app_content/4`: +> +> ```elixir +> defp default_liveview_port do +> 4200 + :erlang.phash2(:, 800) +> end +> ``` +> +> Generated `mob.exs` ships `# config :mob, liveview_port: 4200` +> commented out — uncomment to pin a specific value (e.g. for a test +> harness that hardcodes a port). `Mob.LiveView.local_url/1` reads +> the env automatically, so the WebView URL stays in sync with the +> resolved port without further changes. **Symptom** — second LV app fails to start with: ``` @@ -207,7 +234,12 @@ regenerate). --- -## 5. `mix mob.deploy --native --ios` silently prefers iPhone over sim +## 5. `mix mob.deploy --native --ios` silently prefers iPhone over sim — **FIXED 2026-05-10** + +> **Resolution.** Option #1 (the recommended one): when +> `auto_detect_physical_ios/0` picks an iPhone and a sim is also booted, +> it now prints the alternative `--device ` invocation so the +> user can target the sim explicitly. Default behavior unchanged. **Symptom** — `mix mob.deploy --native --ios` builds and installs on the physical iPhone, never the booted simulator. No log line indicates the From a2542aad4f07635778c78689562ec34feaf1a203 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:35:26 -0600 Subject: [PATCH 007/254] ios: AX modifiers on Slider + Toggle so Mob.Test can drive them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes mob/issues.md #7 + #8 — both were AX-conformance bugs in the SwiftUI components that backed `` and `` in the renderer. #7: `MobSlider` had no `.accessibilityAdjustableAction`. SwiftUI ignores VoiceOver / `accessibilityIncrement` calls without it; the NIF returned :ok but the value never moved. Added the modifier with default step `(max - min) / 10` (matches VoiceOver's default for native UISlider) and a wired callback so on-change events fire too. #8: `MobToggle` used SwiftUI's `Toggle("Label", isOn:)` form, which does NOT propagate the label string into the underlying control's `accessibilityLabel`. The AX tree exposed the toggle as a button with empty label, so `Mob.Test.toggle(node, "Notifications")` returned :label_not_found. Added `.accessibilityLabel(label)` after the toggle so the visible text reaches the AX tree. Both fixes are in `ios/MobRootView.swift`. Android counterparts (`MobSlider` / `MobToggle` in MobBridgeKt) need analogous `Modifier.semantics { setProgress(...) / contentDescription = ... }` calls — tracked separately under issues.md #11 (Compose semantics). --- ios/MobRootView.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 2cb432a8..9191ac26 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -1050,6 +1050,13 @@ private struct MobToggle: View { node.onChangeBool?(newValue) } .frame(maxWidth: .infinity, alignment: .leading) + // issues.md #8: SwiftUI's Toggle("Label", …) initializer does + // not propagate the label string into the underlying control's + // accessibilityLabel — the AX tree exposes the visual Text as + // a separate node and the Switch as a button with empty label. + // Setting it here lets `Mob.Test.toggle(node, "Notifications")` + // find the toggle via plain label match. + .accessibilityLabel(label) } } @@ -1064,12 +1071,31 @@ private struct MobSlider: View { } var body: some View { + // issues.md #7: SwiftUI's plain Slider doesn't emit AX adjustable + // actions unless `.accessibilityAdjustableAction` is attached. Without + // it, VoiceOver users (and `Mob.Test.adjust_slider/4` which calls the + // same AX API) see :ok back from increment/decrement but the value + // never changes. Default step is (max - min) / 10 — the same default + // VoiceOver picks for native UISlider when no explicit step is set. + let step = (node.maxValue - node.minValue) / 10.0 Slider(value: $value, in: node.minValue...node.maxValue) .onChange(of: value) { _, newValue in node.onChangeFloat?(newValue) } .tint(node.color.map { Color($0) } ?? Color.accentColor) .frame(maxWidth: .infinity) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + value = Swift.min(value + step, node.maxValue) + node.onChangeFloat?(value) + case .decrement: + value = Swift.max(value - step, node.minValue) + node.onChangeFloat?(value) + @unknown default: + break + } + } } } From 973b851c15836b8c39fb0b8910ce56271ec13ceb Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:36:13 -0600 Subject: [PATCH 008/254] issues: mark #7, #8 fixed and #14 worked-around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 + #8 — `MobSlider` and `MobToggle` in MobRootView.swift gained the SwiftUI AX modifiers SwiftUI doesn't apply by default (.accessibilityAdjustableAction with default step (max-min)/10 for the slider; .accessibilityLabel(label) for the toggle). Mob.Test end-to-end driving works after these. #14 — defensive fallback in MobDev.Connector. When the primary node name times out the connector now also tries the alternate `_ios@127.0.0.1` form (without the udid suffix). The connected Device.node is updated to whichever responded so downstream RPC calls use the correct address. The root cause (mob_beam.m sometimes not seeing SIMULATOR_UDID) is captured in the resolution note for a future investigation. Remaining: #3 (WebSocket→longpoll, investigation), #6 (16 KB page alignment, OTP rebuild), #9 (Alert OK button, UIKit deep dive), #10 (Android 17 SELinux, OTP source patch), #11 (Android Compose semantics walker, ~200 lines Kotlin). --- issues.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/issues.md b/issues.md index e1b9e1ab..0f6fdb21 100644 --- a/issues.md +++ b/issues.md @@ -393,7 +393,13 @@ phones will be Android 15+). --- -## 7. iOS Slider doesn't honor `accessibilityIncrement`/`Decrement` +## 7. iOS Slider doesn't honor `accessibilityIncrement`/`Decrement` — **FIXED 2026-05-10** + +> **Resolution.** `MobSlider` in `ios/MobRootView.swift` now has +> `.accessibilityAdjustableAction { direction in … }` with default step +> `(max - min) / 10` (matches VoiceOver's default for native UISlider). +> Increments/decrements call `node.onChangeFloat?` so `:change` events +> still flow. `Mob.Test.adjust_slider/4` works end-to-end after this. **Symptom** — `Mob.Test.adjust_slider/4` (and direct `mob_nif:ax_action_at_xy(x, y, :increment)`) returns `:ok` but the slider's value never changes. Verified 2026-04-30 against `mob_test`'s ControlsScreen on a real iPhone with VoiceOver active. @@ -426,7 +432,15 @@ This unblocks `Mob.Test.adjust_slider/4` end-to-end. Same component on Android ( --- -## 8. iOS Toggle's `label:` prop doesn't reach the AX tree +## 8. iOS Toggle's `label:` prop doesn't reach the AX tree — **FIXED 2026-05-10** + +> **Resolution.** `MobToggle` in `ios/MobRootView.swift` now appends +> `.accessibilityLabel(label)` after the `Toggle("Label", isOn:)` view. +> SwiftUI's Toggle initializer doesn't propagate the label string into +> the underlying control's accessibilityLabel, so this is the explicit +> bridge. After the fix, the toggle appears in `ui_tree` as +> `:button label="Notifications" value="1"` and +> `Mob.Test.toggle(node, "Notifications")` finds it via plain match. **Symptom** — `Mob.Test.toggle/2` returns `{:error, :label_not_found}` because the visible label text doesn't appear in `mob_nif:ui_tree/0`. The toggle itself comes through as: @@ -788,7 +802,24 @@ problem. --- -## 14. iOS sim's distribution node name doesn't match `mix mob.connect`'s expectation +## 14. iOS sim's distribution node name doesn't match `mix mob.connect`'s expectation — **WORKED AROUND 2026-05-10** + +> **Resolution.** Option (1) from the fix list — defensive fallback in +> `mob_dev/lib/mob_dev/connector.ex`. `wait_for_nodes/2` now builds a +> per-device candidate list and tries each in parallel via +> `try_connect_each/2`. For iOS sims the list is +> `[_ios_@127.0.0.1, _ios@127.0.0.1]`; first responder +> wins and the connected `Device.node` is updated to whichever name +> actually registered. The output surfaces the alternate name when the +> fallback is used so the user can copy it for direct RPC. +> +> **Root cause still TBD.** The fallback works around the symptom but +> doesn't explain why `mob_beam.m`'s `getenv("SIMULATOR_UDID")` sometimes +> returns NULL in launch contexts where it should be set. Worth +> investigating: confirm via `simctl spawn printenv | grep SIM` +> on a freshly-deployed sim, then trace through the launcher chain +> (`xcrun simctl install` then user-tap vs `simctl launch`). The fix +> there belongs in mob_beam.m (or the launch path that drops the var). **Symptom** — After `mix mob.deploy --native --ios --device `, running `mix mob.connect --no-iex` shows the sim node as a timeout while From 7dbc1f07f35457f41024f59e52d08291b593acd6 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 19:45:31 -0600 Subject: [PATCH 009/254] =?UTF-8?q?test:=20fix=20renderer=20flake=20?= =?UTF-8?q?=E2=80=94=20canvas=20describe=20leaked=20theme=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "canvas draw-op encoding" describe block has two tests that mutate the global theme via `Mob.Theme.set(primary: :emerald_500)` to verify that draw-op `:color` resolves the same way as render-tree props. Both ran without an on_exit reset, so the mutated theme persisted into later tests in the same file. When ExUnit's randomized order put one of those tests before "style token resolution > color atom in background is resolved to ARGB integer", the latter's `assert background == 0xFF2196F3` (default blue_500) saw the leaked emerald primary instead and failed. Verified: 5/5 consecutive runs clean after adding the on_exit. Same cleanup pattern as the sibling "theme token resolution" describe. --- test/mob/renderer_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index 5b0ccf69..1f35da2c 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -866,6 +866,16 @@ defmodule Mob.RendererTest do # color props. These tests pin the wire shape AND the resolution behavior. describe "canvas draw-op encoding" do + setup do + # Two tests in this describe call `Mob.Theme.set(primary: :emerald_500)` + # to verify draw-op token resolution. Without an on_exit reset the + # mutated theme persisted into later tests (e.g. the "style token + # resolution" describe's `assert background == 0xFF2196F3` started + # asserting against whatever color the theme leaked). + on_exit(fn -> Application.delete_env(:mob, :theme) end) + :ok + end + defp canvas_draw(ops) do tree = %{type: :canvas, props: %{width: 100, height: 100, draw: ops}, children: []} MockNIF.reset() From 331a66a09e4e7e066016d06371d3bf5c09f4b635 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 21:51:20 -0600 Subject: [PATCH 010/254] build_system_migration: Phase 3 iter 1 logged mix mob.add_nif scaffold landed in mob_dev. First Igniter-backed surface; validates AST-aware generation before the Phase 4/5 rewrites. --- build_system_migration.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index cd61aea1..4bcebd74 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -756,3 +756,31 @@ Apple framework module maps under -fmodules — Phase 1 finding). gone. The iOS path no longer uses shell scripts at all; build orchestration lives in Elixir (`mob_dev/lib/mob_dev/native_build.ex`) and Zig (`build.zig` / `build_device.zig`). + +## Phase 3 — `mix mob.add_nif` (in progress) + +Greenfield Igniter usage. First commit to Igniter as a dep; lays the +groundwork for the Phase 4/5 rewrites by validating the AST-aware +generator pattern on a small, self-contained task. + + - iter 1: scaffold task. `mix mob.add_nif [--type elixir-only|c]` + creates `lib//nifs/.ex` (Elixir stub via + `Igniter.Project.Module.create_module`), appends + `%{module: :, archs: [:all]}` to `mob.exs`'s `:static_nifs` + via `Igniter.Project.Config.modify_config_code/5`, and (with + `--type c`) drops a `c_src/.c` skeleton with + `ERL_NIF_INIT(, ...)` pre-wired. Validates name is + snake_case + length-bounded; `--type` is `elixir-only` or `c` + (zigler/rustler land in later iters that pull in those Hex deps). + Idempotent — re-running with the same name skips file writes and + keeps the existing list entry. Drive-by fix: `mix mob.regen_driver_tab` + was reading from `Application.get_env(:mob_dev, :static_nifs, [])` + but mob.exs is not auto-imported into Mix application env, so the + user's `:static_nifs` entries never reached driver_tab. Switched + regen to `MobDev.Config.load_mob_config()` matching every other + mob_dev task. Smoke-tested against `phase2q_smoke`: deploy added + one NIF, regen produced driver_tab containing the entry, second + add appended to the existing list cleanly. 20 new tests covering + validation/stub/append/idempotence/C-skeleton/notice paths. + `igniter ~> 0.8` added to mob_dev deps (the Phase 3 dep + commitment). 850/850 tests pass on mob_dev master. From 2889a2c80735ea83d61abfa85907af2883568d8f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 10 May 2026 23:15:58 -0600 Subject: [PATCH 011/254] build_system_migration: Phase 3 COMPLETE mix mob.add_nif scaffolds Elixir-only / C / Zigler / Rustler NIFs, composes regen, and ships docs. 864/864 mob_dev tests pass. Phase 4 (mob.enable Igniter rewrite) is next. --- build_system_migration.md | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index 4bcebd74..7b33e2a3 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -784,3 +784,52 @@ generator pattern on a small, self-contained task. validation/stub/append/idempotence/C-skeleton/notice paths. `igniter ~> 0.8` added to mob_dev deps (the Phase 3 dep commitment). 850/850 tests pass on mob_dev master. + + - iter 2: auto-regen via `Igniter.add_task/3`. The post-run notice + that asked the user to run `mix mob.regen_driver_tab` manually is + gone — `mob.add_nif` queues regen to fire after Igniter commits, + so the same shell invocation produces stub + mob.exs update + + optional native skeleton + regenerated driver_tab. Single command, + one diff, one confirm. + + - iter 3: `--type zigler`. Generates a Zigler-backed stub + (`use Zig, otp_app: :` + inline `~Z` sigil with example + `pub fn add_one`). Adds `:zigler ~> 0.15` to mix.exs deps via + `Igniter.Project.Deps.add_dep/2`. Skips `c_src/.c` (Zigler + manages its own native side via the sigil + zig-build pipeline). + Stub moduledoc warns about the static-link gap: Zigler's default + flow produces a dlopen'd `.so`, incompatible with Mob's iOS + App Store / Android RTLD_LOCAL constraints; on-device shipping + requires the user to wire the Zigler archive into ios/build.zig + + android/jni/ manually. Host-dev path works out-of-the-box. 6 + new tests. + + - iter 4: `--type rustler`. Generates a Rustler-backed stub + (`use Rustler, otp_app: :, crate: ""`) plus a full + Cargo crate skeleton at `native//`: `Cargo.toml` with + `crate-type = ["cdylib"]` (Rustler default; comment documents + the `staticlib` swap for Mob), `src/lib.rs` with example + `#[rustler::nif] fn add_one` + `rustler::init!` correctly + pointing at the generated Elixir module name, and `.gitignore` + excluding `/target`. Adds `:rustler ~> 0.32` to mix.exs deps. + Same static-link warning as zigler. 8 new tests; updated the + unknown-type test to use `haskell` (since `rustler` is now + valid). + + - iter 5: docs. `README.md` gains a top-level `mix mob.add_nif` + section with the four `--type` variants, the matrix of generated + files + Hex deps per type, and an explicit static-link gotcha + callout for zigler/rustler. The Mix tasks table now lists + `mob.add_nif` and `mob.regen_driver_tab` (the latter wasn't + documented at all before). `AGENTS.md` adds two gotchas: don't + edit `:static_nifs` in mob.exs by hand (use `mob.add_nif`), and + `mob.regen_driver_tab` reads from `Config.Reader` not + `Application.env` (template for future `:static_nifs` consumers). + + **Phase 3 is COMPLETE.** `mix mob.add_nif ` covers all four + backend types (`elixir-only`, `c`, `zigler`, `rustler`), composes + with regen automatically, and is documented in README + AGENTS.md. + 864/864 mob_dev tests pass on master. First Igniter-backed task + validated end-to-end; pattern is ready to apply to the heavier + Phase 4 (`mob.enable` rewrite) and Phase 5 (`mob.new --liveview` + generator rewrite). From f154318449c89ac143312a9a31187c807961b782 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 07:23:37 -0600 Subject: [PATCH 012/254] build_system_migration: Phase 4 iter 1 logged mob.enable now uses Igniter.Mix.Task; all 7 features (camera, photo_library, location, file_sharing, notifications, liveview, python) route through MobDev.Enable.Igniter handlers. Stop criterion ("all features use Igniter consistently") is hit. iter 2 deepens AST-awareness for the features that touch Elixir source (python dep injection); iter 3 is docs. --- build_system_migration.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index 7b33e2a3..f7479936 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -833,3 +833,29 @@ generator pattern on a small, self-contained task. validated end-to-end; pattern is ready to apply to the heavier Phase 4 (`mob.enable` rewrite) and Phase 5 (`mob.new --liveview` generator rewrite). + +## Phase 4 — `mix mob.enable` → Igniter (in progress) + +The plan called for 3-4 weeks, one feature per iter (camera → ... +→ python). Iter 1 collapsed that into a single sweep because the +per-feature text-mutation logic is small enough to wrap uniformly. + + - iter 1: `Mix.Tasks.Mob.Enable` is now `use Igniter.Mix.Task`. + All seven features (camera, photo_library, location, + file_sharing, notifications, liveview, python) dispatch through + `MobDev.Enable.Igniter` per-feature handlers that return + `igniter -> igniter`. Wins from the conversion: + - Single diff preview + atomic apply across all features. + - Per-handler idempotency via Igniter's `update_file` rather + than the legacy "read content, conditional write, log to + Mix.shell" pattern (which scattered idempotency checks). + - Missing platform dirs → notices instead of silent file-not- + found, so the user sees what was/wasn't done. + The text-mutation logic itself (the Sourceror regex patches in + `MobDev.Enable`) is unchanged from the legacy path — kept inside + the Igniter wrappers. AST-aware deepening for the two features + that touch Elixir source (liveview + python) is iter 2. + 8 new tests; 38 legacy `MobDev.EnableTest` helper tests still pass. + 872/872 mob_dev tests pass on master. Smoke-tested on + phase2q_smoke: `mix mob.enable photo_library --yes` added the + plist key cleanly with the expected notice about Android. From f0e4984b7a88af8f06e65f707f1483c3cd0193a4 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 07:33:26 -0600 Subject: [PATCH 013/254] build_system_migration: Phase 4 COMPLETE mob.enable is fully Igniter-driven. All 7 features route through MobDev.Enable.Igniter handlers; AST-aware helpers cover every Elixir-source mutation in the enable path (pythonx dep via Project.Deps.add_dep, mob_screen + python_paths modules via Project.Module.create_module). The regex-on-mix.exs sweep flagged as the plan's highest fragility is gone. 875/875 mob_dev tests pass. Phase 5 (mob.new --liveview generator rewrite) is next. --- build_system_migration.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index f7479936..29fe6b15 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -859,3 +859,44 @@ per-feature text-mutation logic is small enough to wrap uniformly. 872/872 mob_dev tests pass on master. Smoke-tested on phase2q_smoke: `mix mob.enable photo_library --yes` added the plist key cleanly with the expected notice about Android. + + - iter 2: AST-aware pythonx dep injection. Replaces + `MobDev.Enable.inject_pythonx_dep/1` (regex on mix.exs source) + with `Igniter.Project.Deps.add_dep({:pythonx, "~> 0.4"})` — parses + the project's `defp deps do [...]` AST and appends in-place, + idempotent automatically. Liveview's `mob_screen.ex` and python's + `python_paths.ex` generation already went through + `Igniter.Project.Module.create_module` in iter 1, so this closes + the last text-on-Elixir manipulation in `mob.enable`. The + remaining text-level patches (JS, HEEX, plist, AndroidManifest) + are non-Elixir source and stay text-level — AST tooling for those + isn't a win. Drive-by fix: `mix mob.enable` was reading the + on-disk mix.exs for app name, which under `Igniter.test_project` + saw mob_dev's own app name; switched to + `Igniter.Project.Application.app_name/1` with on-disk fallback. + 3 new tests under the python describe; 875/875 mob_dev tests pass. + + - iter 3: docs. `README.md` gains a top-level + `mix mob.enable ` section with a per-feature surface + table (iOS / Android / Elixir columns) and the diff-preview UX + notes. The Mix tasks index gains a `mob.enable` row. + `AGENTS.md` adds three gotchas: how to add a new feature + (dispatch + handler + valid_features list, plus when to reach for + AST-aware Igniter helpers vs text-level `update_file`); file + discovery in `Enable.Igniter` must use Igniter's view of the + filesystem (not raw `File.exists?`) so `test_project` virtualized + files are findable; app name reads should go through + `Igniter.Project.Application.app_name/1` rather than the on-disk + mix.exs. + + **Phase 4 is COMPLETE.** `mix mob.enable` is fully Igniter-driven; + all seven features route through `MobDev.Enable.Igniter` handlers + with diff preview, atomic apply, and idempotent semantics. AST-aware + Igniter helpers (`Project.Deps.add_dep`, `Project.Module.create_module`, + `Project.Config.modify_config_code`) cover every Elixir-source + mutation in the enable path; the regex-on-mix.exs sweep that the + plan flagged as the highest fragility is gone. 875/875 mob_dev + tests pass on master. + + Phase 5 (the `mob.new --liveview` generator rewrite — biggest + remaining fragility per the plan) is next. From 196e385f0f296fc4b912243f1da50e139d81de65 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 07:44:35 -0600 Subject: [PATCH 014/254] build_system_migration: Phase 5 COMPLETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inject_deps now uses Sourceror AST instead of regex on mix.exs. The stop criterion ("no more regex-patched Elixir source in the LV generator") is hit. Phase 5's surface was smaller than the plan estimated — most of live_view_patcher.ex is fresh-file templates, not patches against existing source. Build-system migration phases 0-5 complete. Phase 6 polish work (comptime driver_tab.zig, Android C→Zig, release-script zig cc) remains independently valuable but not blocking. --- build_system_migration.md | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index 29fe6b15..f08d8835 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -900,3 +900,57 @@ per-feature text-mutation logic is small enough to wrap uniformly. Phase 5 (the `mob.new --liveview` generator rewrite — biggest remaining fragility per the plan) is next. + +## Phase 5 — `mob.new --liveview` AST rewrite (COMPLETE) + +The plan called this "highest-value rewrite — biggest fragility removed." +On audit, the actual surface was smaller than expected: the file is +923 lines, but most of it is string templates that generate FRESH +Elixir files (mob_screen.ex, page_live.ex, repo.ex, …), not regex +patches against existing source. The one remaining regex-on-Elixir +was `inject_deps/3` — patching the user's mix.exs after `mix phx.new` +runs. Two iters closed it. + + - iter 1: AST-aware `inject_deps` via Sourceror. The old version + matched `defp deps do\\s*\\[` and inserted dep tuples at the head + of the list. Brittle when phx.new's mix.exs varied across Phoenix + versions or formatter configs — and we had debugged it twice + already (`:re.import/1` + Elixir version drift in the OTP 29 + rebuild). The new flow: + - `Sourceror.parse_string(content)` — full AST with comments. + - `Macro.prewalk` walks to `def(p) deps do [...] end`. + - Append the parsed dep tuples to the list. + - `Sourceror.to_string(ast)` — round-trip back to source. + Idempotency now scans the AST for `:mob` declarations regardless + of indentation or trailing-comma shape. Bails out safely (returns + content unchanged) when the deps function uses an unmatched shape + like `defp deps, do: [...]` shorthand. + + `sourceror ~> 1.0` added to mob_new deps. Chose direct Sourceror + over Igniter because Igniter's `Project.Deps.add_dep` is tied to + igniter state + the full Igniter.Mix.Task lifecycle, both overkill + for a one-shot patch from inside a regular Mix.Task generator. + Same underlying AST machinery, simpler boundary. + + 3 new tests covering the new AST cases (empty deps list, + shorthand form no-op, round-trip parse to validate output is + still legal Elixir) plus the existing 4 inject_deps tests + unchanged. 224/224 mob_new tests pass. + + - iter 2: docs. AGENTS.md gotchas gains two bullets — one on the + AST-vs-regex convention for future maintainers, one on + sourceror's archive-size cost. README unchanged (inject_deps is + internal, not user-facing API). + + **Phase 5 is COMPLETE.** The "regex-patched Elixir source in the + LV generator" stop criterion is hit. The remaining regex usage in + `live_view_patcher.ex` operates on JavaScript (`inject_mob_hook`) + and HEEX (`inject_mob_bridge_element`) source — both non-Elixir + and not in scope for AST tooling. The `insert_hooks_before_closing` + helper that wires MobHook into the LiveSocket call already uses + brace-depth line tracking instead of regex, by design. + + Build-system migration phases 0-5 are complete. Remaining work + (Phase 6 polish — comptime driver_tab.zig, Android C → Zig, + release-script zig cc swap deferred from Phase 1) is independently + valuable but not blocking. From 1dbe73a71533d20472f858f75f2cb7f1bfe344f0 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 07:55:31 -0600 Subject: [PATCH 015/254] Phase 6a iter 1: driver_tab.zig reference impl for iOS + Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-coded Zig translations of the existing driver_tab_{ios,android}.c reference snapshots. Validates that Zig's `export` keyword produces the C-ABI symbols libbeam.a expects — `erts_static_nif_tab[]`, `driver_tab[]`, and `erts_init_static_drivers()` — for both Apple (iOS sim + iOS device) and Linux (Android) targets. Verified by standalone object-file compilation: ios/driver_tab_ios.zig: aarch64-ios-simulator → 0000000000000010 D _erts_static_nif_tab aarch64-ios → (same shape, device target) android/jni/driver_tab_android.zig: aarch64-linux-android → 0000000000000000 D erts_static_nif_tab All three platforms produce the right symbol layout. Comptime structure in the iOS file (`if (sqlite_static) base ++ [sqlite, sentinel] else base ++ [sentinel]`) replaces the `#ifdef MOB_STATIC_SQLITE_NIF` from the C version — cleaner than C preprocessor, same output. Both .zig files live as siblings to the existing .c files. This iter ships the reference implementations only; iter 2 wires build.zig to accept .zig via an `addZigObject` helper alongside `addCObject`; iter 3 updates `MobDev.StaticNifs.generate/2` to emit Zig from the mob.exs manifest. The build templates and `mix mob.regen_driver_tab` are unchanged, so existing projects continue using the .c files. Once iter 2+3 land, the regen task switches output format and Zig becomes the default. --- android/jni/driver_tab_android.zig | 87 +++++++++++++++++++ ios/driver_tab_ios.zig | 130 +++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 android/jni/driver_tab_android.zig create mode 100644 ios/driver_tab_ios.zig diff --git a/android/jni/driver_tab_android.zig b/android/jni/driver_tab_android.zig new file mode 100644 index 00000000..96868476 --- /dev/null +++ b/android/jni/driver_tab_android.zig @@ -0,0 +1,87 @@ +//! driver_tab_android.zig — Reference snapshot of the static NIF table (Zig rewrite). +//! +//! Phase 6a of the build-system migration: hand-coded Zig sibling to +//! driver_tab_android.c, matching it byte-for-byte semantically. +//! Validates that Zig's `export` keyword produces the C-ABI symbols +//! libbeam.a expects (`erts_static_nif_tab`, `driver_tab`, +//! `erts_init_static_drivers`). +//! +//! Link BEFORE libbeam.a so this overrides BEAM's built-in empty +//! `erts_static_nif_tab[]` and `driver_tab[]`. + +// ── ABI types ────────────────────────────────────────────────────────────── + +const ErtsStaticDriver = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const ErtsStaticNif = extern struct { + nif_init: ?*const fn () callconv(.c) ?*anyopaque, + is_builtin: c_int, + nif_mod: c_ulong, + entry: ?*anyopaque, +}; + +const ErlDrvEntryStub = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const THE_NON_VALUE: c_ulong = 0; + +// ── External driver entry refs (from libbeam.a / OTP) ────────────────────── + +extern var inet_driver_entry: ErlDrvEntryStub; +extern var ram_file_driver_entry: ErlDrvEntryStub; + +// ── External NIF init refs ───────────────────────────────────────────────── + +extern fn prim_tty_nif_init() callconv(.c) ?*anyopaque; +extern fn erl_tracer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_buffer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_file_nif_init() callconv(.c) ?*anyopaque; +extern fn zlib_nif_init() callconv(.c) ?*anyopaque; +extern fn zstd_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_socket_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_net_nif_init() callconv(.c) ?*anyopaque; +extern fn asn1rt_nif_nif_init() callconv(.c) ?*anyopaque; + +// crypto.c's ERL_NIF_INIT(crypto, ...) generates crypto_nif_init. +// Built into libpigeon.so via crypto.a + libcrypto.a (OpenSSL). +// Without this entry, the BEAM falls through to dlopen("crypto.so") which +// fails because Android's RTLD_LOCAL hides libpigeon.so's enif_* symbols +// from the dlopen'd library. With it, the BEAM resolves crypto via +// dlsym(RTLD_DEFAULT) and load_nif uses the static path — no dlopen, +// real OpenSSL. +extern fn crypto_nif_init() callconv(.c) ?*anyopaque; + +// mob_nif.c's ERL_NIF_INIT(mob_nif, ...) generates mob_nif_nif_init. +extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; + +// ── Static driver table ──────────────────────────────────────────────────── + +export var driver_tab: [3]ErtsStaticDriver = .{ + .{ .de = &inet_driver_entry, .flags = 0 }, + .{ .de = &ram_file_driver_entry, .flags = 0 }, + .{ .de = null, .flags = 0 }, +}; + +export fn erts_init_static_drivers() callconv(.c) void {} + +// ── Static NIF table ─────────────────────────────────────────────────────── + +export var erts_static_nif_tab = [_]ErtsStaticNif{ + .{ .nif_init = prim_tty_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = erl_tracer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_buffer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_file_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zlib_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zstd_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_socket_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_net_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = asn1rt_nif_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = crypto_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = mob_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = null, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, +}; diff --git a/ios/driver_tab_ios.zig b/ios/driver_tab_ios.zig new file mode 100644 index 00000000..0cb7bf66 --- /dev/null +++ b/ios/driver_tab_ios.zig @@ -0,0 +1,130 @@ +//! driver_tab_ios.zig — Reference snapshot of the static NIF table (Zig rewrite). +//! +//! Phase 6a of the build-system migration: the per-app source-of-truth +//! for static NIFs still lives in `mob.exs`'s `:static_nifs` (regenerated +//! by `mix mob.regen_driver_tab`), but the *output* shape moves from C +//! to Zig. The hand-written file below matches the C version byte-for- +//! byte semantically, validates the C-ABI exports libbeam.a expects, and +//! gives later iters a comptime-friendly structure to build on. +//! +//! Link BEFORE libbeam.a so this overrides BEAM's built-in empty +//! `erts_static_nif_tab[]` and `driver_tab[]`. + +// ── ABI types ────────────────────────────────────────────────────────────── +// Layouts mirror the C structs in libbeam.a. They use Zig's `extern struct` +// so the field order + alignment matches the C ABI exactly. + +const ErtsStaticDriver = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const ErtsStaticNif = extern struct { + nif_init: ?*const fn () callconv(.c) ?*anyopaque, + is_builtin: c_int, + nif_mod: c_ulong, + entry: ?*anyopaque, +}; + +const ErlDrvEntryStub = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +// NON-VALUE sentinel matches the C `#define THE_NON_VALUE` — used as +// `nif_mod` for entries the BEAM populates at load time. +const THE_NON_VALUE: c_ulong = 0; + +// ── External driver entry refs (from libbeam.a / OTP) ────────────────────── + +extern var inet_driver_entry: ErlDrvEntryStub; +extern var ram_file_driver_entry: ErlDrvEntryStub; + +// ── External NIF init refs ───────────────────────────────────────────────── +// Each ERL_NIF_INIT(name, ...) macro in NIF source files generates a +// `_nif_init` C function. We declare them here as extern so the +// table below can reference them. + +extern fn prim_tty_nif_init() callconv(.c) ?*anyopaque; +extern fn erl_tracer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_buffer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_file_nif_init() callconv(.c) ?*anyopaque; +extern fn zlib_nif_init() callconv(.c) ?*anyopaque; +extern fn zstd_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_socket_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_net_nif_init() callconv(.c) ?*anyopaque; +extern fn asn1rt_nif_nif_init() callconv(.c) ?*anyopaque; + +// crypto.c's ERL_NIF_INIT(crypto, ...) generates crypto_nif_init. +// Built into the app binary via crypto.a + libcrypto.a (OpenSSL). +// Same pattern as Android — see driver_tab_android.{c,zig} for rationale +// (Android RTLD_LOCAL hides parent's enif_* symbols from dlopen'd +// children; iOS App Store likewise rejects dynamic NIFs in the bundle). +extern fn crypto_nif_init() callconv(.c) ?*anyopaque; + +// mob_nif.m's ERL_NIF_INIT(mob_nif, ...) with -DSTATIC_ERLANG_NIF +// generates: mob_nif_nif_init. +extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; + +// exqlite's sqlite3_nif is linked statically on device only. The build +// system passes a comptime flag to opt in (wired through build.zig in +// iter 2). For now `sqlite_static` is a plain comptime constant — its +// default is false (the simulator path), and device builds will +// override at compile time via the build module options system. +const sqlite_static: bool = false; +extern fn sqlite3_nif_nif_init() callconv(.c) ?*anyopaque; + +// ── Static driver table ──────────────────────────────────────────────────── +// inet + ram_file are the only drivers in the iOS bundle. NULL-terminator +// at the end matches the C version exactly. + +export var driver_tab: [3]ErtsStaticDriver = .{ + .{ .de = &inet_driver_entry, .flags = 0 }, + .{ .de = &ram_file_driver_entry, .flags = 0 }, + .{ .de = null, .flags = 0 }, +}; + +// erts_init_static_drivers is a hook BEAM calls during init. We have +// no drivers to register dynamically — the table above is the whole +// story — so the function is empty. Matches the C version. +export fn erts_init_static_drivers() callconv(.c) void {} + +// ── Static NIF table ─────────────────────────────────────────────────────── +// Comptime-built so adding the conditional sqlite3_nif entry is a clean +// `if` rather than `#ifdef`. The output array length adjusts automatically. + +const base_nifs = [_]ErtsStaticNif{ + .{ .nif_init = prim_tty_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = erl_tracer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_buffer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_file_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zlib_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zstd_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_socket_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_net_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = asn1rt_nif_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = crypto_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = mob_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, +}; + +const sqlite_nif = ErtsStaticNif{ + .nif_init = sqlite3_nif_nif_init, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + +const sentinel = ErtsStaticNif{ + .nif_init = null, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + +export var erts_static_nif_tab = blk: { + if (sqlite_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite_nif, sentinel }; + } else { + break :blk base_nifs ++ [_]ErtsStaticNif{sentinel}; + } +}; From aebbf8460aa925970d33f65410d9b5e48876e169 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 08:16:29 -0600 Subject: [PATCH 016/254] Phase 6a iter 3 (mob side): driver_tab_ios.zig imports build_options Switch the hand-coded sqlite_static const to `@import("build_options").sqlite_static` so the same .zig file works for both the sim build (build.zig passes an Options module with sqlite_static=false) and the device build (build_device.zig with the real flag from `-Dsqlite_static=true`). Iter 1 had it hardcoded to false for standalone-compile validation; iter 3's build template wiring makes the Options module always-present, so the import resolves deterministically. Pairs with: - mob_dev iter 3: StaticNifs Zig output + regen --format zig - mob_new iter 3: build_device.zig + build.zig addZigObject takes a `build_options` field; pass through to mod.addOptions(). End-to-end: a fresh `mix mob.new` project that emits .zig driver_tabs (once mob_dev's regen task is wired to default Zig in a later iter) can now build through both build.zig (sim) and build_device.zig (device) without ever touching the .c path. --- ios/driver_tab_ios.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/driver_tab_ios.zig b/ios/driver_tab_ios.zig index 0cb7bf66..c9fa1d35 100644 --- a/ios/driver_tab_ios.zig +++ b/ios/driver_tab_ios.zig @@ -67,11 +67,11 @@ extern fn crypto_nif_init() callconv(.c) ?*anyopaque; extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; // exqlite's sqlite3_nif is linked statically on device only. The build -// system passes a comptime flag to opt in (wired through build.zig in -// iter 2). For now `sqlite_static` is a plain comptime constant — its -// default is false (the simulator path), and device builds will -// override at compile time via the build module options system. -const sqlite_static: bool = false; +// system threads the flag in via `b.addOptions()` in build_device.zig +// (iter 3); see addZigObject. For simulator builds the option module +// either isn't provided OR has sqlite_static = false. +const build_options = @import("build_options"); +const sqlite_static = build_options.sqlite_static; extern fn sqlite3_nif_nif_init() callconv(.c) ?*anyopaque; // ── Static driver table ──────────────────────────────────────────────────── From d23ea2c018f4f5547dfaf69b2caa9fbcd3394f3e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 08:26:21 -0600 Subject: [PATCH 017/254] build_system_migration: Phase 6a functionally complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit driver_tab is now generated as Zig from mob.exs via `mix mob.regen_driver_tab --format zig`. End-to-end deploy validated: regen produces priv/generated/driver_tab_*.zig with comptime gates for guarded NIFs (sqlite_static via b.addOptions); NativeBuild prefers .zig over .c when resolving the driver_tab path; build.zig auto-detects extension. C path remains the default for backward compat. Bonus: Zig's addCSourceFile silently routes .zig files to the Zig compiler, so older project build.zig files (no addZigObject helper) keep working when they get a .zig driver_tab — no regeneration needed. Phase 6a follow-ups (regen-on-compile, mob.new defaults to Zig) are optional polish. --- build_system_migration.md | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index f08d8835..19a1b876 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -954,3 +954,61 @@ runs. Two iters closed it. (Phase 6 polish — comptime driver_tab.zig, Android C → Zig, release-script zig cc swap deferred from Phase 1) is independently valuable but not blocking. + +## Phase 6a — `driver_tab.zig` comptime-generated (in progress) + +The plan called for moving driver_tab from generated C to generated +Zig with comptime structure replacing `#ifdef` preprocessor gates. +Shipped in three iters across all three repos; end-to-end Zig path +validated on phase2q_smoke. + + - iter 1 (mob): hand-coded `driver_tab_{ios,android}.zig` as + reference impl. Validates Zig's `export` keyword produces the + C-ABI symbols libbeam.a expects (`erts_static_nif_tab`, + `driver_tab`, `erts_init_static_drivers`). Standalone-compile + against all three targets — aarch64-ios-simulator, + aarch64-ios, aarch64-linux-android — all produce the right + symbol layout. Comptime `if (sqlite_static) ... else ...` + replaces the C `#ifdef MOB_STATIC_SQLITE_NIF`. + + - iter 2 (mob_new): build.zig template gains `addZigObject` + helper paralleling `addCObject`. driver_tab call site + auto-detects file extension and routes to the right helper. + + - iter 3 (mob_dev + mob_new + mob): + * mob_dev: `MobDev.StaticNifs.generate/3` accepts + `format: :c | :zig` (defaults to :c). The Zig output mirrors + the hand-coded reference from iter 1, with comptime gates + for guarded NIFs. `mix mob.regen_driver_tab --format zig` + writes `priv/generated/driver_tab_{ios,android}.zig`. + `MobDev.NativeBuild`'s three driver_tab resolution sites + prefer .zig over .c in priv/generated, falling back to mob's + reference files in either extension. + * mob_new: build_device.zig template gets the same + addZigObject helper plus `b.addOptions(sqlite_static)` for + the device path. build.zig (sim) provides + sqlite_static=false so the same .zig file compiles + unconditionally for both targets. + * mob: `ios/driver_tab_ios.zig` switches `sqlite_static` from + iter-1's hardcoded false to + `@import("build_options").sqlite_static`. + + Smoke test on phase2q_smoke: regen --format zig + clean + `mix mob.deploy --native --device ` succeeded. The + `.zig-cache/o//driver_tab_ios.o` has the expected C-ABI + exports (`_erts_static_nif_tab`, `_driver_tab`, + `_erts_init_static_drivers`). Happy discovery: Zig's + `addCSourceFile` auto-detects .zig extension and routes to the + Zig compiler internally, so older project build.zig files + (without an `addZigObject` helper) keep working unchanged when + they receive a .zig driver_tab path. + + 7 new StaticNifs tests covering both formats including round- + trip `zig ast-check` of generated output. 882/882 mob_dev tests, + 224/224 mob_new tests pass. + + Phase 6a is functionally complete — Zig is a first-class + driver_tab format end-to-end. Optional follow-ups (subsequent + iters): wire `mix mob.regen_driver_tab` into `mix compile` so the + regen step disappears from user workflow entirely; default new + projects to Zig driver_tab via `mix mob.new`. From b1666ab5a636c9d44d537fd70cd1a2834eee9b25 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 08:59:38 -0600 Subject: [PATCH 018/254] Phase 6a iter 4 (mob side): delete redundant driver_tab_*.c reference files `mix mob.regen_driver_tab` now defaults to Zig (with auto-detect fallback to C for legacy projects), and mob's reference fallback in NativeBuild's resolve_driver_tab_* prefers .zig. The .c reference files (`ios/driver_tab_ios.c`, `android/jni/driver_tab_android.c`) were never read by fresh projects after iter 3, and they confuse readers who see them sitting next to the .zig versions thinking they're load-bearing. Removed cleanly. The .zig files now stand alone as the canonical reference implementations. Anyone wanting a hand-editable .c can still produce one via `mix mob.regen_driver_tab --format c` in their project. --- android/jni/driver_tab_android.c | 68 -------------------------- ios/driver_tab_ios.c | 82 -------------------------------- 2 files changed, 150 deletions(-) delete mode 100644 android/jni/driver_tab_android.c delete mode 100644 ios/driver_tab_ios.c diff --git a/android/jni/driver_tab_android.c b/android/jni/driver_tab_android.c deleted file mode 100644 index 247ac575..00000000 --- a/android/jni/driver_tab_android.c +++ /dev/null @@ -1,68 +0,0 @@ -// driver_tab_android.c — Reference snapshot of the static NIF table. -// -// As of mob 0.5.18 + mob_dev 0.4.x, the source of truth for an app's static -// NIF table lives in the app's mob.exs `:static_nifs` config and is generated -// to priv/generated/driver_tab_android.c via `mix mob.regen_driver_tab`. This -// file remains as a fallback that build templates use when the generated file -// is absent (i.e. the project hasn't been migrated yet). -// -// Keep this file in sync with `MobDev.StaticNifs.default_nifs/0` so the -// fallback matches the generator's default output. -// -// Link BEFORE libbeam.a to override the built-in driver_tab. - -#include - -typedef struct { - void *de; - int flags; -} ErtsStaticDriver; -#define THE_NON_VALUE ((unsigned long)0) -typedef struct { - void *(*nif_init)(void); - int is_builtin; - unsigned long nif_mod; - void *entry; -} ErtsStaticNif; - -typedef struct { - void *de; - int flags; -} ErlDrvEntryStub; -extern ErlDrvEntryStub inet_driver_entry; -extern ErlDrvEntryStub ram_file_driver_entry; - -ErtsStaticDriver driver_tab[] = {{&inet_driver_entry, 0}, {&ram_file_driver_entry, 0}, {NULL, 0}}; - -void erts_init_static_drivers(void) { -} - -void *prim_tty_nif_init(void); -void *erl_tracer_nif_init(void); -void *prim_buffer_nif_init(void); -void *prim_file_nif_init(void); -void *zlib_nif_init(void); -void *zstd_nif_init(void); -void *prim_socket_nif_init(void); -void *prim_net_nif_init(void); -void *asn1rt_nif_nif_init(void); - -// crypto.c's ERL_NIF_INIT(crypto,...) generates: crypto_nif_init. -// Built into libpigeon.so via crypto.a + libcrypto.a (OpenSSL). -// Without this entry, the BEAM falls through to dlopen("crypto.so") -// which fails because Android's RTLD_LOCAL hides libpigeon.so's -// enif_* symbols from the dlopen'd library. With it, the BEAM -// resolves crypto via dlsym(RTLD_DEFAULT) and load_nif uses the -// static path — no dlopen, real OpenSSL. -void *crypto_nif_init(void); - -// mob_nif.c's ERL_NIF_INIT(mob_nif,...) generates: mob_nif_nif_init -void *mob_nif_nif_init(void); - -ErtsStaticNif erts_static_nif_tab[] = { - {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, {NULL, 0, THE_NON_VALUE, NULL}}; diff --git a/ios/driver_tab_ios.c b/ios/driver_tab_ios.c deleted file mode 100644 index 0a2f5e32..00000000 --- a/ios/driver_tab_ios.c +++ /dev/null @@ -1,82 +0,0 @@ -// driver_tab_ios.c — Reference snapshot of the static NIF table. -// -// As of mob 0.5.18 + mob_dev 0.4.x, the source of truth for an app's static -// NIF table lives in the app's mob.exs `:static_nifs` config and is generated -// to priv/generated/driver_tab_ios.c via `mix mob.regen_driver_tab`. This -// file remains as a fallback that build templates use when the generated file -// is absent (i.e. the project hasn't been migrated yet). -// -// Keep this file in sync with `MobDev.StaticNifs.default_nifs/0` so the -// fallback matches the generator's default output. -// -// Link BEFORE libbeam.a to override the built-in driver_tab. - -#include - -typedef struct { - void *de; - int flags; -} ErtsStaticDriver; -#define THE_NON_VALUE ((unsigned long)0) -typedef struct { - void *(*nif_init)(void); - int is_builtin; - unsigned long nif_mod; - void *entry; -} ErtsStaticNif; - -typedef struct { - void *de; - int flags; -} ErlDrvEntryStub; -extern ErlDrvEntryStub inet_driver_entry; -extern ErlDrvEntryStub ram_file_driver_entry; - -ErtsStaticDriver driver_tab[] = {{&inet_driver_entry, 0}, {&ram_file_driver_entry, 0}, {NULL, 0}}; - -void erts_init_static_drivers(void) { -} - -void *prim_tty_nif_init(void); -void *erl_tracer_nif_init(void); -void *prim_buffer_nif_init(void); -void *prim_file_nif_init(void); -void *zlib_nif_init(void); -void *zstd_nif_init(void); -void *prim_socket_nif_init(void); -void *prim_net_nif_init(void); -void *asn1rt_nif_nif_init(void); - -// crypto.c's ERL_NIF_INIT(crypto, ...) generates: crypto_nif_init. -// Built into the app binary via crypto.a + libcrypto.a (OpenSSL). -// Same pattern as Android — see driver_tab_android.c for the rationale -// (Android RTLD_LOCAL hides parent's enif_* symbols from dlopen'd -// children; iOS App Store likewise rejects dynamic NIFs in the bundle). -void *crypto_nif_init(void); - -// mob_nif.m's ERL_NIF_INIT(mob_nif,...) with -DSTATIC_ERLANG_NIF -// generates function name: mob_nif_nif_init -void *mob_nif_nif_init(void); - -// exqlite sqlite3_nif is linked statically on device (pass -DMOB_STATIC_SQLITE_NIF -// when compiling this file in device builds). On simulator it loads dynamically -// as a .so and must NOT appear in the static table. -#ifdef MOB_STATIC_SQLITE_NIF -void *sqlite3_nif_nif_init(void); -#endif - -ErtsStaticNif erts_static_nif_tab[] = {{prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, - {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, - {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, - {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, -#ifdef MOB_STATIC_SQLITE_NIF - {sqlite3_nif_nif_init, 0, THE_NON_VALUE, NULL}, -#endif - {NULL, 0, THE_NON_VALUE, NULL}}; From 53b0c43f310e6663613e0815168934eaa3199a1c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 09:08:29 -0600 Subject: [PATCH 019/254] =?UTF-8?q?build=5Fsystem=5Fmigration:=20Phase=206?= =?UTF-8?q?a=20COMPLETE=20=E2=80=94=20Zig=20is=20default=20driver=5Ftab=20?= =?UTF-8?q?format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iter 4 flips the regen default to :zig with build.zig-aware auto-detect for backward compat. Old projects with legacy build.zig (no addZigObject helper) get .c output. New projects get .zig. --format zig and --format c override explicitly. C NIF authoring path unchanged: --type c for scaffolding works as before; user-authored .c NIFs link via standard C ABI. Only the auto-generated dispatch table file moved to Zig. mob's redundant driver_tab_*.c reference files deleted; mob_new template comments mention .{zig,c} extension. --- build_system_migration.md | 48 +++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index 19a1b876..6de47508 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1007,8 +1007,46 @@ validated on phase2q_smoke. trip `zig ast-check` of generated output. 882/882 mob_dev tests, 224/224 mob_new tests pass. - Phase 6a is functionally complete — Zig is a first-class - driver_tab format end-to-end. Optional follow-ups (subsequent - iters): wire `mix mob.regen_driver_tab` into `mix compile` so the - regen step disappears from user workflow entirely; default new - projects to Zig driver_tab via `mix mob.new`. + - iter 4: Zig is the default `mix mob.regen_driver_tab` format. + `parse_format(nil)` now calls `detect_default_format/0` which + reads `ios/build.zig` and picks: + * `:zig` if the file contains `addZigObject` (post-iter-2 + template); + * `:c` otherwise (legacy template that can't compile .zig + source via `addCObject`'s addCSourceFile pipeline on Zig + 0.17-dev); + * `:zig` when no build.zig exists yet (rare). + This makes the regen-default flip safe in-place: existing + projects continue producing C output that their old build.zig + can handle, while freshly-generated projects pick up Zig + automatically. `--format zig` / `--format c` override the + auto-detect explicitly. + + C NIF authors are fully unaffected: `mix mob.add_nif --type c` + still drops `c_src/.c`; the Zig dispatch table calls into + user C code via standard C ABI (`extern fn _nif_init() + callconv(.c)`); `--format c` is always available for projects + that want hand-editable dispatch tables. + + mob's redundant `ios/driver_tab_ios.c` + + `android/jni/driver_tab_android.c` reference files deleted — + nothing reads them after iter 3's resolve_driver_tab_* prefers + .zig. mob_new template comments + `b.option` help-strings + updated to mention `.{zig,c}` to reflect the new default with + legacy support. + + Smoke-tested both detection paths against `phase2q_smoke` + (legacy template) and a marker-injected variant (simulated new + template). Auto-detect picks the right format both ways; + deploys succeed end-to-end. 13/13 regen tests pass (added 2 + auto-detect-path tests); 884/884 mob_dev tests pass overall. + + **Phase 6a is COMPLETE.** Zig is the default driver_tab format + across the build system. Old projects keep working via the + auto-detect fallback to C. New projects ship Zig out of the box. + C NIF authoring path remains fully supported via `--type c` for + scaffolding and `--format c` for dispatch-table output. + + Optional further follow-ups (not required for Phase 6a closure): + wire `mix mob.regen_driver_tab` into `mix compile` so the regen + step disappears from user workflow entirely. From fbe2dbb3f279950023756b0b72f86a9350515a15 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 09:23:38 -0600 Subject: [PATCH 020/254] build_system_migration: Phase 6b iter 1 logged Android build.zig template now has the addZigObject helper + .zig/.c auto-detect in its source iteration. Toolchain plumbing for Phase 6b is in place; the actual mob_beam.c and mob_nif.c translations (which are the bulk of the ~3100-line C surface in Android) start in iter 2+. --- build_system_migration.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index 6de47508..426bc732 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1050,3 +1050,32 @@ validated on phase2q_smoke. Optional further follow-ups (not required for Phase 6a closure): wire `mix mob.regen_driver_tab` into `mix compile` so the regen step disappears from user workflow entirely. + +## Phase 6b — Android C → Zig (in progress) + +The plan: migrate Android's `mob_nif.c` (~2570 lines) and +`mob_beam.c` (~540 lines) — about 3100 lines of C with non-trivial +JNI ergonomics — to Zig. Done incrementally so each iter ships +something useful even if the total project pauses. + + - iter 1 (toolchain plumbing): mob_new's Android build.zig template + gets the `addZigObject` helper and auto-detects file extension + in its source iteration loop. Same pattern the iOS templates use + (Phase 6a iter 2-3). The four sources Android handles — + driver_tab_android, mob_nif, mob_beam, beam_jni — can each be + `.zig` or `.c` now without touching the call site. + + `b.option` help-string + header comment updated to acknowledge + `.{zig,c}` extension. mob_dev's NativeBuild already resolves + `driver_tab_android.zig` over `.c` since Phase 6a iter 3, so the + full chain works end-to-end without further mob_dev work. + + Verified: rendered Android build.zig.eex through EEx + + `zig ast-check` passes; full Android native build through + `mix mob.deploy --native --device ` succeeds against + phase2q_smoke with the new template. 224/224 mob_new tests pass. + + Iter 1 ships only the build plumbing. The real translation + (mob_beam.c → mob_beam.zig as iter 2; mob_nif.c → mob_nif.zig + across several iters as iter 3+) starts from a known-working + toolchain. From 5421f6fab382f2547552524c49f752f25cf6edb9 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 12:19:32 -0600 Subject: [PATCH 021/254] =?UTF-8?q?Phase=206b=20iter=202=20(mob=20side):?= =?UTF-8?q?=20port=20mob=5Fbeam.c=20=E2=86=92=20mob=5Fbeam.zig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Zig port of Android's BEAM launcher (~540 lines). All load-bearing behaviour preserved byte-for-byte: cold-start race fix (window-focus wait that prevents the FORTIFY pthread_mutex SIGABRT against hwui's first-draw setup), SELinux exec rules for ERTS bin symlinks, Play Store split-APK fallback paths for exqlite/pythonx priv-dir wiring, BEAM stdio → logcat capture. Foundation FFI bindings hand-declared in `android/jni/mob_zig.zig` (~470 lines: JNI vtable, libc, Android log, dlfcn, pthreads). Zig 0.17-dev's @cImport builtin is gone and `zig translate-c` hangs at 99% CPU on the Android NDK's jni.h (deep recursive include tree). Hand-declaring sidesteps both. Surface is stable — JNI ABI hasn't materially changed since Java 1.1 (1997). Reusable for the upcoming iter 3+ mob_nif.zig work. Comptime gates replace `#ifdef`: • `no_beam` — battery baseline config; default false • `beam_flags_mode` — picks default scheduler tuning argv ("untuned" / "sbwt_only" / "nerves_full"); default "nerves_full". Runtime override (`beams_dir/mob_beam_flags`, written by `mix mob.deploy --schedulers N`) still wins. Verified: object compiles cleanly for aarch64-linux-android.24. Exports `mob_init_bridge`, `mob_start_beam`, `mob_ui_cache_class` match the C surface; undefined references match what mob_nif.c and bionic/libbeam provide. Wiring into mob_new's Android build.zig template lands in the companion iter-2 mob_new commit. mob_beam.c deleted; mob_beam.h retained (still included by the per-app beam_jni.c). Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 5 +- android/jni/mob_beam.c | 537 ------------------------------- android/jni/mob_beam.zig | 675 +++++++++++++++++++++++++++++++++++++++ android/jni/mob_zig.zig | 470 +++++++++++++++++++++++++++ 4 files changed, 1148 insertions(+), 539 deletions(-) delete mode 100644 android/jni/mob_beam.c create mode 100644 android/jni/mob_beam.zig create mode 100644 android/jni/mob_zig.zig diff --git a/CLAUDE.md b/CLAUDE.md index 736b8973..a7200e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,7 +360,7 @@ app code just calls `Mob.Dist.ensure_started(node: :"my_app_android@127.0.0.1", ERTS helper binaries (`erl_child_setup`, `inet_gethost`, `epmd`) cannot be exec'd from the app data directory (SELinux `app_data_file` blocks `execute_no_trans`). They are packaged in the APK as `lib*.so` in `jniLibs/arm64-v8a/` (gets `apk_data_file` label, which allows exec). -`mob_beam.c` symlinks `BINDIR/` → `/lib.so` before `erl_start`. +`mob_beam.zig` symlinks `BINDIR/` → `/lib.so` before `erl_start`. ## Agent round-trip workflow @@ -548,7 +548,8 @@ User alias "Nova" = macOS + Nix-managed toolchain throughout. - `ios/mob_nif.m` — iOS NIF implementation (SwiftUI bridge + test harness) - `android/jni/mob_nif.c` — Android NIF implementation (JNI bridge) - `ios/mob_beam.m` — iOS BEAM launcher -- `android/jni/mob_beam.c` — Android BEAM launcher +- `android/jni/mob_beam.zig` — Android BEAM launcher (Phase 6b iter 2 — was `.c`) +- `android/jni/mob_zig.zig` — Hand-declared JNI / libc / Android FFI bindings used by mob_beam.zig ## Transport-handler reentrancy: spawn before calling back into the GenServer diff --git a/android/jni/mob_beam.c b/android/jni/mob_beam.c deleted file mode 100644 index 66f4ca66..00000000 --- a/android/jni/mob_beam.c +++ /dev/null @@ -1,537 +0,0 @@ -// mob_beam.c — Mob BEAM launcher and JNI bridge initialisation. -// Extracted from the per-app beam_jni.c stub so app code stays minimal. - -#include "mob_beam.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define LOG_TAG "MobBeam" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -// ── BEAM stdout/stderr → logcat ────────────────────────────────────────── -// Without this, anything the BEAM writes to stderr (including ** crash -// reports from Logger and the boot script's :application.start/2 errors) -// is silently dropped on Android. Wire stdout + stderr to a pipe and read -// them on a detached thread, emitting each line under the "BEAMout" tag. -// One-shot: called once from mob_init_bridge before any BEAM code runs. -// -// See beam_crash.md (Incident #1) for the case that motivated this. - -static void *mob_beam_log_reader(void *arg) { - int fd = (int)(intptr_t)arg; - char buf[4096]; - char line[4096]; - int line_pos = 0; - ssize_t n; - while ((n = read(fd, buf, sizeof(buf))) > 0) { - for (ssize_t i = 0; i < n; i++) { - char c = buf[i]; - if (c == '\n' || line_pos >= (int)sizeof(line) - 1) { - line[line_pos] = '\0'; - if (line_pos > 0) { - __android_log_write(ANDROID_LOG_INFO, "BEAMout", line); - } - line_pos = 0; - } else if (c != '\r') { - line[line_pos++] = c; - } - } - } - return NULL; -} - -static void mob_capture_beam_stdio(void) { - int pipe_fds[2]; - if (pipe(pipe_fds) != 0) { - LOGE("mob_capture_beam_stdio: pipe() failed: %s", strerror(errno)); - return; - } - if (dup2(pipe_fds[1], STDOUT_FILENO) < 0) - LOGE("mob_capture_beam_stdio: dup2 stdout failed: %s", strerror(errno)); - if (dup2(pipe_fds[1], STDERR_FILENO) < 0) - LOGE("mob_capture_beam_stdio: dup2 stderr failed: %s", strerror(errno)); - close(pipe_fds[1]); - - pthread_t tid; - if (pthread_create(&tid, NULL, mob_beam_log_reader, (void *)(intptr_t)pipe_fds[0]) != 0) { - LOGE("mob_capture_beam_stdio: pthread_create failed: %s", strerror(errno)); - close(pipe_fds[0]); - return; - } - pthread_detach(tid); - - // Disable buffering so output reaches the pipe immediately, not on - // exit (which we never reach for a long-running BEAM). - setvbuf(stdout, NULL, _IONBF, 0); - setvbuf(stderr, NULL, _IONBF, 0); - LOGI("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)"); -} - -#define ERTS_VSN "erts-17.0" - -// Declared in mob_nif.c — caches MobBridge methods on the main thread. -extern void _mob_ui_cache_class_impl(JNIEnv *env, const char *bridge_class); - -// Native lib dir and app files dir — populated in mob_init_bridge, used in mob_start_beam. -static char s_native_lib_dir[512] = {0}; -static char s_files_dir[512] = {0}; - -void mob_ui_cache_class(JNIEnv *env, const char *bridge_class) { - _mob_ui_cache_class_impl(env, bridge_class); -} - -// Declared in mob_nif.c — the cached Bridge.cls global ref. -extern void _mob_bridge_init_activity(JNIEnv *env, jobject activity); - -void mob_init_bridge(JNIEnv *env, jobject activity) { - // Capture BEAM stdio first so any startup errors (NIF load failures, - // application:start/2 crashes) land in logcat instead of /dev/null. - mob_capture_beam_stdio(); - - g_activity = (*env)->NewGlobalRef(env, activity); - _mob_bridge_init_activity(env, g_activity); - - // Get nativeLibraryDir so mob_start_beam can symlink ERTS executables there. - // Files in the native lib dir carry the apk_data_file SELinux label which - // allows execve() from untrusted_app, unlike files in app_data_file. - jclass ctx_cls = (*env)->FindClass(env, "android/content/Context"); - jmethodID get_app_info = (*env)->GetMethodID(env, ctx_cls, "getApplicationInfo", - "()Landroid/content/pm/ApplicationInfo;"); - jobject app_info = (*env)->CallObjectMethod(env, activity, get_app_info); - jclass app_info_cls = (*env)->FindClass(env, "android/content/pm/ApplicationInfo"); - jfieldID fid = (*env)->GetFieldID(env, app_info_cls, "nativeLibraryDir", "Ljava/lang/String;"); - jstring jdir = (*env)->GetObjectField(env, app_info, fid); - const char *dir = (*env)->GetStringUTFChars(env, jdir, NULL); - snprintf(s_native_lib_dir, sizeof(s_native_lib_dir), "%s", dir); - (*env)->ReleaseStringUTFChars(env, jdir, dir); - LOGI("mob_init_bridge: native lib dir = %s", s_native_lib_dir); - - // Get filesDir for OTP root path (app-specific, avoids hardcoding package name). - jmethodID get_files_dir = (*env)->GetMethodID(env, ctx_cls, "getFilesDir", "()Ljava/io/File;"); - jobject files_dir_obj = (*env)->CallObjectMethod(env, activity, get_files_dir); - jclass file_cls = (*env)->FindClass(env, "java/io/File"); - jmethodID get_path = (*env)->GetMethodID(env, file_cls, "getPath", "()Ljava/lang/String;"); - jstring jfiles_path = (*env)->CallObjectMethod(env, files_dir_obj, get_path); - const char *files_path = (*env)->GetStringUTFChars(env, jfiles_path, NULL); - snprintf(s_files_dir, sizeof(s_files_dir), "%s", files_path); - (*env)->ReleaseStringUTFChars(env, jfiles_path, files_path); - LOGI("mob_init_bridge: files dir = %s", s_files_dir); -} - -void mob_start_beam(const char *app_module) { -#ifdef NO_BEAM - // Config A: baseline measurement — stock Android activity, BEAM never launched. - LOGI("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)"); - return; -#endif - // Re-dlopen ourselves with RTLD_GLOBAL so the BEAM's enif_* symbols - // (statically linked into this library) are visible when the BEAM - // later dlopens a NIF library (e.g. crypto.so). Without this, Android - // loads libpigeon.so with RTLD_LOCAL by default, hiding enif_* from - // dlopen'd children — crypto.so on_load fails with - // `cannot locate symbol enif_get_tuple`. - { - char self_path[600]; - snprintf(self_path, sizeof(self_path), "%s/lib%s.so", s_native_lib_dir, app_module); - if (!dlopen(self_path, RTLD_NOW | RTLD_GLOBAL)) { - LOGE("mob_start_beam: dlopen self with RTLD_GLOBAL failed: %s", dlerror()); - } else { - LOGI("mob_start_beam: re-dlopened self RTLD_GLOBAL: %s", self_path); - } - } - mob_set_startup_phase("Setting up BEAM environment…"); - // Build all paths dynamically from s_files_dir (set in mob_init_bridge). - char otp_root[560]; - snprintf(otp_root, sizeof(otp_root), "%s/otp", s_files_dir); - - char bindir[600]; - snprintf(bindir, sizeof(bindir), "%s/" ERTS_VSN "/bin", otp_root); - - char beams_dir[600]; - snprintf(beams_dir, sizeof(beams_dir), "%s/%s", otp_root, app_module); - - char elixir_dir[600]; - snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); - - char logger_dir[600]; - snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); - - char eex_dir[600]; - snprintf(eex_dir, sizeof(eex_dir), "%s/lib/eex/ebin", otp_root); - - char crash_dump[560]; - snprintf(crash_dump, sizeof(crash_dump), "%s/erl_crash.dump", s_files_dir); - - setenv("BINDIR", bindir, 1); - setenv("ROOTDIR", otp_root, 1); - setenv("PROGNAME", "erl", 1); - setenv("EMU", "beam", 1); - setenv("HOME", s_files_dir, 1); - setenv("MOB_DATA_DIR", s_files_dir, 1); - - // MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed. - // - // Problem: Ecto.Migrator uses :code.priv_dir(app) to locate migration .exs - // files. :code.priv_dir/1 works by looking up the app's OTP lib structure - // ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps are deployed to a flat -pa - // directory (e.g. files/otp/my_app/*.beam), not an OTP lib structure, so - // :code.priv_dir/1 returns {error, bad_name} and Ecto silently reports - // "Migrations already up" without running anything. - // - // Fix: deployer.ex pushes priv/ alongside the BEAMs into beams_dir/priv/. - // App code reads MOB_BEAMS_DIR at startup and passes the explicit path to - // Ecto.Migrator.run/4 instead of relying on :code.priv_dir/1. This env var - // is the only reliable way to communicate beams_dir to Elixir code since it - // is computed here from getFilesDir() at runtime (the path includes the - // Android user ID which is not predictable at compile time). - setenv("MOB_BEAMS_DIR", beams_dir, 1); - setenv("ERL_CRASH_DUMP", crash_dump, 1); - setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); - - char eval_expr[280]; - snprintf(eval_expr, sizeof(eval_expr), "%s:start().", app_module); - - // Compile-time default BEAM tuning flags. - // Selected by -D flag: BEAM_UNTUNED, BEAM_SBWT_ONLY, BEAM_FULL_NERVES, - // or BEAM_USE_CUSTOM_FLAGS (includes mob_beam_flags.h from battery bench). - // These are overridden at runtime if beams_dir/mob_beam_flags exists. -#ifdef BEAM_USE_CUSTOM_FLAGS -#include "mob_beam_flags.h" - static const char *s_default_flags[] = {BEAM_EXTRA_FLAGS NULL}; -#elif defined(BEAM_UNTUNED) - static const char *s_default_flags[] = {NULL}; -#elif defined(BEAM_SBWT_ONLY) - static const char *s_default_flags[] = {"-sbwt", "none", "-sbwtdcpu", "none", - "-sbwtdio", "none", NULL}; -#else - // Default and BEAM_FULL_NERVES both use full Nerves-style tuning. - static const char *s_default_flags[] = {"-S", "1:1", "-SDcpu", "1:1", "-SDio", - "1", "-A", "1", "-sbwt", "none", - "-sbwtdcpu", "none", "-sbwtdio", "none", NULL}; -#endif - - // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. - // Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`. - static char s_flags_buf[512] = {0}; - static const char *s_runtime_flags[64] = {NULL}; - static int s_runtime_flag_count = 0; - { - char flags_path[640]; - snprintf(flags_path, sizeof(flags_path), "%s/mob_beam_flags", beams_dir); - FILE *f = fopen(flags_path, "r"); - if (f) { - size_t n = fread(s_flags_buf, 1, sizeof(s_flags_buf) - 1, f); - fclose(f); - s_flags_buf[n] = '\0'; - s_runtime_flag_count = 0; - char *p = s_flags_buf; - while (*p && s_runtime_flag_count < 63) { - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') - p++; - if (!*p) - break; - s_runtime_flags[s_runtime_flag_count++] = p; - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') - p++; - if (*p) - *p++ = '\0'; - } - s_runtime_flags[s_runtime_flag_count] = NULL; - LOGI("mob_start_beam: loaded %d runtime flags from %s", s_runtime_flag_count, - flags_path); - } - } - - const char **selected_flags = (s_runtime_flag_count > 0) ? s_runtime_flags : s_default_flags; - - char boot_path[580]; - snprintf(boot_path, sizeof(boot_path), "%s/releases/29/start_clean", otp_root); - - static const char *args[128]; - int ac = 0; - args[ac++] = "beam"; - for (int i = 0; selected_flags[i]; i++) - args[ac++] = selected_flags[i]; - args[ac++] = "--"; - args[ac++] = "-root"; - args[ac++] = otp_root; - args[ac++] = "-bindir"; - args[ac++] = bindir; - args[ac++] = "-progname"; - args[ac++] = "erl"; - args[ac++] = "--"; - args[ac++] = "-noshell"; - args[ac++] = "-noinput"; - args[ac++] = "-boot"; - args[ac++] = boot_path; - args[ac++] = "-pa"; - args[ac++] = elixir_dir; - args[ac++] = "-pa"; - args[ac++] = logger_dir; - args[ac++] = "-pa"; - args[ac++] = eex_dir; - args[ac++] = "-pa"; - args[ac++] = beams_dir; - args[ac++] = "-eval"; - args[ac++] = eval_expr; - args[ac] = NULL; - - // ── Cold-start race condition fix ──────────────────────────────────────── - // - // DO NOT REMOVE THIS BLOCK. - // - // Problem: on a cold start (first launch after install or after the process - // was killed), calling erl_start() too early causes a SIGABRT deep inside - // ERTS pthread initialisation. The crash looks like: - // - // FORTIFY: pthread_mutex_lock called on a destroyed mutex - // backtrace: - // #00 abort - // #01 pthread_mutex_lock (FORTIFY wrapper) - // #02 ... (ERTS internal thread pool setup) - // #03 erl_start - // - // Root cause: Android's hwui (hardware-accelerated UI renderer) creates its - // own native thread pool during the very first layout/draw pass. That - // initialisation uses pthread mutexes that it allocates and later destroys. - // ERTS also calls into pthreads during erl_start(). If erl_start() runs - // concurrently with hwui's first-draw setup, the two pthread paths race on - // the same internal libc state and the FORTIFY mutex check fires → SIGABRT. - // - // The race only reproduces on cold start because: - // • On warm start hwui's thread pool already exists → no race. - // • The window-focus event is the earliest point at which Android - // guarantees the first layout/draw pass has completed, so hwui's - // pthread state is stable. - // - // Fix: poll Activity.hasWindowFocus() every 50 ms before calling erl_start(). - // hasWindowFocus() returns true only after the window has been drawn and - // given input focus, which is *after* hwui finishes its thread-pool setup. - // We wait up to 3 seconds (covers slow emulators and heavily loaded devices) - // and fall through anyway so a stuck window never blocks BEAM forever. - // - // Why this lives here instead of in MainActivity.kt: - // Putting the delay in Kotlin would mean every app built on Mob needs to - // replicate and maintain the fix. Centralising it in mob_beam.c means - // app code can stay a simple `Thread({ nativeStartBeam() }).start()`. - // - // JNI threading notes: - // • beam-main is created via `new Thread()` in Kotlin, so it is already - // attached to the JVM when this function runs. Calling - // AttachCurrentThread on an already-attached thread is a no-op, but - // calling DetachCurrentThread on a Java-created thread makes ART abort. - // • We therefore call GetEnv first. If the thread is already attached - // (needs_detach == 0) we skip both Attach and Detach. Only a purely - // native thread that was never attached would set needs_detach == 1. - if (g_jvm && g_activity) { - mob_set_startup_phase("Waiting for window focus…"); - JNIEnv *env2 = NULL; - int needs_detach = ((*g_jvm)->GetEnv(g_jvm, (void **)&env2, JNI_VERSION_1_6) != JNI_OK); - if (needs_detach) - (*g_jvm)->AttachCurrentThread(g_jvm, &env2, NULL); - - jclass act_cls = (*env2)->GetObjectClass(env2, g_activity); - jmethodID has_focus = (*env2)->GetMethodID(env2, act_cls, "hasWindowFocus", "()Z"); - int waited = 0; - const int max_wait = 3000; /* ms — fall through if focus never arrives */ - while (!(*env2)->CallBooleanMethod(env2, g_activity, has_focus) && waited < max_wait) { - struct timespec ts = {0, 50000000}; /* 50 ms */ - nanosleep(&ts, NULL); - waited += 50; - } - /* Only detach if we attached above — detaching a Java thread aborts ART. */ - if (needs_detach) - (*g_jvm)->DetachCurrentThread(g_jvm); - if (waited >= max_wait) - LOGI("mob_start_beam: focus timeout (%d ms) — starting BEAM anyway", waited); - else if (waited) - LOGI("mob_start_beam: waited %d ms for window focus", waited); - } - // ── end cold-start race condition fix ──────────────────────────────────── - - mob_set_startup_phase("Starting BEAM…"); - LOGI("mob_start_beam: starting BEAM with module=%s, argc=%d", app_module, ac); - - // Symlink ERTS executables from BINDIR to the native lib dir. - // - // When installed via `adb install`, nativeLibraryDir contains the .so files - // and the symlink approach works (apk_data_file SELinux label allows execve). - // - // When installed via Play Store (split APKs), Android does NOT extract .so - // files to nativeLibraryDir on modern devices — they stay inside the split APK - // zip. In that case MobBridge.extractBeamHelpersFromSplitApk() copies the - // binaries directly into erts/bin/ before this point. We detect that scenario - // by checking whether the nativeLibDir target exists: if it doesn't, skip the - // unlink+symlink so we don't clobber the already-extracted real file. - if (s_native_lib_dir[0]) { - static const char *const exes[] = {"erl_child_setup", "inet_gethost", "epmd", NULL}; - static const char *const libs[] = {"liberl_child_setup.so", "libinet_gethost.so", - "libepmd.so", NULL}; - char bin_path[512], lib_path[512]; - for (int i = 0; exes[i]; i++) { - snprintf(bin_path, sizeof(bin_path), "%s/" ERTS_VSN "/bin/%s", otp_root, exes[i]); - snprintf(lib_path, sizeof(lib_path), "%s/%s", s_native_lib_dir, libs[i]); - struct stat lib_st; - if (stat(lib_path, &lib_st) == 0) { - // nativeLibDir has the file (adb install) — use symlink - unlink(bin_path); - if (symlink(lib_path, bin_path) == 0) { - LOGI("mob_start_beam: symlink %s -> %s", exes[i], lib_path); - } else { - LOGE("mob_start_beam: symlink %s failed: %s", exes[i], strerror(errno)); - } - } else { - // nativeLibDir empty (Play Store split APK) — MobBridge should have - // extracted the binary directly to bin_path; leave it in place. - struct stat bin_st; - if (stat(bin_path, &bin_st) == 0) { - LOGI("mob_start_beam: symlink %s (extracted from split APK)", exes[i]); - } else { - LOGE("mob_start_beam: symlink %s missing from both nativeLibDir and bin/", - exes[i]); - } - } - } - } - - // Symlink sqlite3_nif.so into the exqlite OTP lib structure so that - // code:priv_dir(:exqlite) resolves correctly. - // - // The OTP code server registers lib_dirs by scanning $OTP_ROOT/lib/*/ebin - // at boot. For code:lib_dir(:exqlite) to work, exqlite must live at - // $OTP_ROOT/lib/exqlite-VERSION/ — a flat -pa dir is NOT sufficient. - // The deployer creates $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv}; we - // create the sqlite3_nif.so symlink inside priv/ at runtime so the path - // (which contains the APK install hash) is always up-to-date. - if (s_native_lib_dir[0]) { - char nif_target[560]; - snprintf(nif_target, sizeof(nif_target), "%s/libsqlite3_nif.so", s_native_lib_dir); - - // Scan $OTP_ROOT/lib/ for exqlite-* and symlink the NIF in its priv/. - char lib_path[600]; - snprintf(lib_path, sizeof(lib_path), "%s/lib", otp_root); - DIR *d = opendir(lib_path); - int found = 0; - if (d) { - struct dirent *entry; - while ((entry = readdir(d)) != NULL) { - if (strncmp(entry->d_name, "exqlite-", 8) == 0) { - char exqlite_priv[700]; - snprintf(exqlite_priv, sizeof(exqlite_priv), "%s/%s/priv", lib_path, - entry->d_name); - mkdir(exqlite_priv, 0755); - char nif_link[760]; - snprintf(nif_link, sizeof(nif_link), "%s/sqlite3_nif.so", exqlite_priv); - struct stat nif_lib_st; - if (stat(nif_target, &nif_lib_st) == 0) { - // nativeLibDir has the NIF (adb install) — use symlink - unlink(nif_link); - if (symlink(nif_target, nif_link) == 0) { - LOGI("mob_start_beam: symlink exqlite NIF -> %s", nif_target); - found = 1; - } else { - LOGE("mob_start_beam: symlink exqlite NIF failed: %s", strerror(errno)); - } - } else { - // nativeLibDir empty — MobBridge extracted NIF directly to nif_link - struct stat nif_file_st; - if (stat(nif_link, &nif_file_st) == 0) { - LOGI("mob_start_beam: exqlite NIF extracted from split APK"); - found = 1; - } else { - LOGE("mob_start_beam: exqlite NIF missing from both nativeLibDir and " - "priv/"); - } - } - break; - } - } - closedir(d); - } - - if (!found) { - // Fallback: symlink into flat beams_dir/priv/ for backward compatibility - // while the deployer hasn't yet created the versioned lib structure. - char priv_dir[660]; - snprintf(priv_dir, sizeof(priv_dir), "%s/priv", beams_dir); - mkdir(priv_dir, 0755); - char nif_link[720]; - snprintf(nif_link, sizeof(nif_link), "%s/sqlite3_nif.so", priv_dir); - struct stat nif_lib_fb_st; - if (stat(nif_target, &nif_lib_fb_st) == 0) { - unlink(nif_link); - if (symlink(nif_target, nif_link) == 0) { - LOGI("mob_start_beam: symlink sqlite3_nif.so (fallback) -> %s", nif_target); - } else { - LOGE("mob_start_beam: symlink sqlite3_nif (fallback) failed: %s", - strerror(errno)); - } - } else { - struct stat nif_fb_file_st; - if (stat(nif_link, &nif_fb_file_st) == 0) { - LOGI("mob_start_beam: sqlite3_nif.so (fallback) extracted from split APK"); - } else { - LOGE("mob_start_beam: sqlite3_nif.so (fallback) missing — NIF load will fail"); - } - } - } - } - - // Symlink libpythonx.so into the pythonx OTP lib structure for the - // same reason as exqlite above. Pythonx's NIF on_load does - // path = :filename.join(:code.priv_dir(:pythonx), 'libpythonx') - // :erlang.load_nif(path, 0) - // For dlopen to resolve enif_* (defined in the main app native lib) - // the .so has to live in the app's namespace — i.e. nativeLibraryDir. - // mob_dev's NativeBuild already places libpythonx.so in jniLibs, so - // the APK installer extracts it to nativeLibraryDir at install time. - // We just symlink into the OTP lib priv/ to make :code.priv_dir - // return a path that dlopen can follow. - if (s_native_lib_dir[0]) { - char pyx_target[560]; - snprintf(pyx_target, sizeof(pyx_target), "%s/libpythonx.so", s_native_lib_dir); - - struct stat pyx_target_st; - if (stat(pyx_target, &pyx_target_st) == 0) { - char lib_path[600]; - snprintf(lib_path, sizeof(lib_path), "%s/lib", otp_root); - DIR *d2 = opendir(lib_path); - if (d2) { - struct dirent *entry; - while ((entry = readdir(d2)) != NULL) { - if (strncmp(entry->d_name, "pythonx-", 8) == 0) { - char pyx_priv[700]; - snprintf(pyx_priv, sizeof(pyx_priv), "%s/%s/priv", lib_path, entry->d_name); - mkdir(pyx_priv, 0755); - char pyx_link[760]; - snprintf(pyx_link, sizeof(pyx_link), "%s/libpythonx.so", pyx_priv); - unlink(pyx_link); - if (symlink(pyx_target, pyx_link) == 0) { - LOGI("mob_start_beam: symlink pythonx NIF -> %s", pyx_target); - } else { - LOGE("mob_start_beam: symlink pythonx NIF failed: %s", strerror(errno)); - } - break; - } - } - closedir(d2); - } - } - } - - void erl_start(int, char **); - erl_start(ac, (char **)args); - mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details"); - LOGE("mob_start_beam: erl_start returned (unexpected)"); -} diff --git a/android/jni/mob_beam.zig b/android/jni/mob_beam.zig new file mode 100644 index 00000000..64b5a060 --- /dev/null +++ b/android/jni/mob_beam.zig @@ -0,0 +1,675 @@ +//! mob_beam.zig — Mob BEAM launcher and JNI bridge initialisation (Android). +//! +//! Phase 6b iter 2 of the build-system migration: Zig port of the original +//! mob_beam.c. Behaviour is intentionally byte-for-byte equivalent — every +//! load-bearing comment in the C version (cold-start race fix, SELinux exec +//! rules, Play Store split-APK fallback, exqlite/pythonx priv-dir symlinks) +//! is preserved verbatim because future maintainers will hit the same +//! constraints and need the same explanations in front of them. +//! +//! The FFI surface (JNI vtable, libc, Android log, dlfcn, pthreads) lives in +//! mob_zig.zig — see that file's header for why we hand-declare it (Zig +//! 0.17-dev's @cImport is gone and `zig translate-c` hangs on the NDK's +//! jni.h). +//! +//! Symbols defined elsewhere in the link: +//! * mob_nif.c provides `_mob_ui_cache_class_impl`, +//! `_mob_bridge_init_activity`, +//! `mob_set_startup_phase`, +//! `mob_set_startup_error`, +//! `g_jvm`, `g_activity`. +//! * libbeam.a provides `erl_start`. + +const std = @import("std"); +const jni = @import("mob_zig.zig"); +const build_options = @import("build_options"); + +// ── Comptime build flags ────────────────────────────────────────────────── +// Compile-time knobs threaded in by build.zig via `b.addOptions()`. +// +// * `no_beam` — Config A: baseline measurement. BEAM never launched, the +// activity stays a stock Android shell. Used for battery benchmarks. +// * `beam_flags_mode` — picks the default scheduler-tuning argv shape: +// - "untuned": no flags (stock Erlang defaults) +// - "sbwt_only": only -sbwt none / -sbwtdcpu / -sbwtdio (cuts the +// scheduler-busy-wait idle drain; lightest tuning) +// - "nerves_full": full Nerves-style tuning (-S 1:1 -SDcpu 1:1 ...) +// (default) +// +// The runtime override (beams_dir/mob_beam_flags) supersedes either default. +const NO_BEAM: bool = build_options.no_beam; +const BEAM_FLAGS_MODE: []const u8 = build_options.beam_flags_mode; + +// ── Logging ─────────────────────────────────────────────────────────────── + +const LOG_TAG: [*:0]const u8 = "MobBeam"; + +inline fn logi(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_INFO, LOG_TAG, fmt, args); +} + +inline fn loge(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_ERROR, LOG_TAG, fmt, args); +} + +inline fn lastErrno() [*:0]const u8 { + return jni.strerror(jni.__errno().*); +} + +// ── Externs from mob_nif.c ──────────────────────────────────────────────── +// Forward declarations for symbols that live next to us in the final .so. +// These are defined by mob_nif.c (kept C in iter 2 — port in a later iter). + +extern fn _mob_ui_cache_class_impl(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void; +extern fn _mob_bridge_init_activity(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void; +extern fn mob_set_startup_phase(phase: [*:0]const u8) callconv(.c) void; +extern fn mob_set_startup_error(err: [*:0]const u8) callconv(.c) void; + +// Global JVM pointer + Activity global ref. Defined in mob_nif.c, populated +// from JNI_OnLoad / mob_init_bridge. Both may be null until those run. +extern var g_jvm: ?*jni.JavaVM; +extern var g_activity: jni.JObject; + +// ── Extern from libbeam.a ───────────────────────────────────────────────── +// BEAM entry point. erl_start blocks forever in the normal case; returning +// is an unexpected-exit condition we report and let the OS reap the process. +extern fn erl_start(argc: c_int, argv: [*]const ?[*:0]const u8) callconv(.c) void; + +// ── Constants ───────────────────────────────────────────────────────────── + +const ERTS_VSN: []const u8 = "erts-17.0"; + +// ── Module-level state ──────────────────────────────────────────────────── +// Populated in mob_init_bridge, read by mob_start_beam. Sized generously +// so paths under /data/data//files/... never truncate. + +var s_native_lib_dir: [512]u8 = @splat(0); +var s_files_dir: [512]u8 = @splat(0); + +// Runtime BEAM flag override loaded from beams_dir/mob_beam_flags. +// In-place tokenised (NULs replace whitespace), pointers indexed into the +// buffer. Same shape as the C version. +var s_flags_buf: [512]u8 = @splat(0); +var s_runtime_flags: [64]?[*:0]const u8 = @splat(null); +var s_runtime_flag_count: usize = 0; + +// ── Small helpers ───────────────────────────────────────────────────────── + +/// Format `fmt`/`args` into `buf`, NUL-terminating the result. Returns a +/// `[*:0]const u8` view of the buffer. Mirrors `snprintf(buf, sizeof(buf), ...)`. +fn formatZ(buf: []u8, comptime fmt: []const u8, args: anytype) [*:0]const u8 { + std.debug.assert(buf.len > 0); + const slice = std.fmt.bufPrint(buf, fmt, args) catch buf[0 .. buf.len - 1]; + const end = @min(slice.len, buf.len - 1); + buf[end] = 0; + return @ptrCast(buf.ptr); +} + +inline fn isWhitespace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\n' or c == '\r'; +} + +// ── BEAM stdout/stderr → logcat ────────────────────────────────────────── +// Without this, anything the BEAM writes to stderr (including ** crash +// reports from Logger and the boot script's :application.start/2 errors) +// is silently dropped on Android. Wire stdout + stderr to a pipe and read +// them on a detached thread, emitting each line under the "BEAMout" tag. +// One-shot: called once from mob_init_bridge before any BEAM code runs. +// +// See beam_crash.md (Incident #1) for the case that motivated this. + +fn mobBeamLogReader(arg: ?*anyopaque) callconv(.c) ?*anyopaque { + const fd: c_int = @intCast(@intFromPtr(arg)); + var buf: [4096]u8 = undefined; + var line: [4096]u8 = undefined; + var line_pos: usize = 0; + while (true) { + const n = jni.read(fd, &buf, buf.len); + if (n <= 0) break; + const got: usize = @intCast(n); + var i: usize = 0; + while (i < got) : (i += 1) { + const c = buf[i]; + if (c == '\n' or line_pos >= line.len - 1) { + line[line_pos] = 0; + if (line_pos > 0) { + const cstr: [*:0]const u8 = @ptrCast(&line); + _ = jni.__android_log_write(jni.ANDROID_LOG_INFO, "BEAMout", cstr); + } + line_pos = 0; + } else if (c != '\r') { + line[line_pos] = c; + line_pos += 1; + } + } + } + return null; +} + +fn mobCaptureBeamStdio() void { + var pipe_fds: [2]c_int = undefined; + if (jni.pipe(&pipe_fds) != 0) { + loge("mob_capture_beam_stdio: pipe() failed: {s}", .{lastErrno()}); + return; + } + if (jni.dup2(pipe_fds[1], jni.STDOUT_FILENO) < 0) { + loge("mob_capture_beam_stdio: dup2 stdout failed: {s}", .{lastErrno()}); + } + if (jni.dup2(pipe_fds[1], jni.STDERR_FILENO) < 0) { + loge("mob_capture_beam_stdio: dup2 stderr failed: {s}", .{lastErrno()}); + } + _ = jni.close(pipe_fds[1]); + + var tid: jni.PthreadT = 0; + const arg: ?*anyopaque = @ptrFromInt(@as(usize, @intCast(pipe_fds[0]))); + if (jni.pthread_create(&tid, null, mobBeamLogReader, arg) != 0) { + loge("mob_capture_beam_stdio: pthread_create failed: {s}", .{lastErrno()}); + _ = jni.close(pipe_fds[0]); + return; + } + _ = jni.pthread_detach(tid); + + // Disable buffering so output reaches the pipe immediately, not on + // exit (which we never reach for a long-running BEAM). + _ = jni.setvbuf(jni.stdout, null, jni._IONBF, 0); + _ = jni.setvbuf(jni.stderr, null, jni._IONBF, 0); + logi("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)", .{}); +} + +// ── Public entry points ─────────────────────────────────────────────────── + +export fn mob_ui_cache_class(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void { + _mob_ui_cache_class_impl(env, bridge_class); +} + +export fn mob_init_bridge(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void { + // Capture BEAM stdio first so any startup errors (NIF load failures, + // application:start/2 crashes) land in logcat instead of /dev/null. + mobCaptureBeamStdio(); + + const activity_global = jni.newGlobalRef(env, activity); + g_activity = activity_global; + _mob_bridge_init_activity(env, activity_global); + + // Get nativeLibraryDir so mob_start_beam can symlink ERTS executables there. + // Files in the native lib dir carry the apk_data_file SELinux label which + // allows execve() from untrusted_app, unlike files in app_data_file. + const ctx_cls = jni.findClass(env, "android/content/Context"); + const get_app_info = jni.getMethodID(env, ctx_cls, "getApplicationInfo", "()Landroid/content/pm/ApplicationInfo;"); + const app_info = jni.callObjectMethod(env, activity, get_app_info); + const app_info_cls = jni.findClass(env, "android/content/pm/ApplicationInfo"); + const fid = jni.getFieldID(env, app_info_cls, "nativeLibraryDir", "Ljava/lang/String;"); + const jdir = jni.getObjectField(env, app_info, fid); + if (jni.getStringUTFChars(env, jdir)) |dir| { + jni.copyZ(&s_native_lib_dir, dir); + jni.releaseStringUTFChars(env, jdir, dir); + } + logi("mob_init_bridge: native lib dir = {s}", .{jni.asCStr(&s_native_lib_dir)}); + + // Get filesDir for OTP root path (app-specific, avoids hardcoding package name). + const get_files_dir = jni.getMethodID(env, ctx_cls, "getFilesDir", "()Ljava/io/File;"); + const files_dir_obj = jni.callObjectMethod(env, activity, get_files_dir); + const file_cls = jni.findClass(env, "java/io/File"); + const get_path = jni.getMethodID(env, file_cls, "getPath", "()Ljava/lang/String;"); + const jfiles_path = jni.callObjectMethod(env, files_dir_obj, get_path); + if (jni.getStringUTFChars(env, jfiles_path)) |fp| { + jni.copyZ(&s_files_dir, fp); + jni.releaseStringUTFChars(env, jfiles_path, fp); + } + logi("mob_init_bridge: files dir = {s}", .{jni.asCStr(&s_files_dir)}); +} + +export fn mob_start_beam(app_module: [*:0]const u8) callconv(.c) void { + if (NO_BEAM) { + // Config A: baseline measurement — stock Android activity, BEAM never launched. + logi("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)", .{}); + return; + } + + // Re-dlopen ourselves with RTLD_GLOBAL so the BEAM's enif_* symbols + // (statically linked into this library) are visible when the BEAM + // later dlopens a NIF library (e.g. crypto.so). Without this, Android + // loads libpigeon.so with RTLD_LOCAL by default, hiding enif_* from + // dlopen'd children — crypto.so on_load fails with + // `cannot locate symbol enif_get_tuple`. + { + var self_path_buf: [600]u8 = undefined; + const self_path = formatZ(&self_path_buf, "{s}/lib{s}.so", .{ + jni.asCStr(&s_native_lib_dir), + app_module, + }); + if (jni.dlopen(self_path, jni.RTLD_NOW | jni.RTLD_GLOBAL) == null) { + const err: [*:0]const u8 = jni.dlerror() orelse "unknown"; + loge("mob_start_beam: dlopen self with RTLD_GLOBAL failed: {s}", .{err}); + } else { + logi("mob_start_beam: re-dlopened self RTLD_GLOBAL: {s}", .{self_path}); + } + } + + mob_set_startup_phase("Setting up BEAM environment…"); + + // Build all paths dynamically from s_files_dir (set in mob_init_bridge). + var otp_root_buf: [560]u8 = undefined; + const otp_root = formatZ(&otp_root_buf, "{s}/otp", .{jni.asCStr(&s_files_dir)}); + + var bindir_buf: [600]u8 = undefined; + const bindir = formatZ(&bindir_buf, "{s}/{s}/bin", .{ otp_root, ERTS_VSN }); + + var beams_dir_buf: [600]u8 = undefined; + const beams_dir = formatZ(&beams_dir_buf, "{s}/{s}", .{ otp_root, app_module }); + + var elixir_dir_buf: [600]u8 = undefined; + const elixir_dir = formatZ(&elixir_dir_buf, "{s}/lib/elixir/ebin", .{otp_root}); + + var logger_dir_buf: [600]u8 = undefined; + const logger_dir = formatZ(&logger_dir_buf, "{s}/lib/logger/ebin", .{otp_root}); + + var eex_dir_buf: [600]u8 = undefined; + const eex_dir = formatZ(&eex_dir_buf, "{s}/lib/eex/ebin", .{otp_root}); + + var crash_dump_buf: [560]u8 = undefined; + const crash_dump = formatZ(&crash_dump_buf, "{s}/erl_crash.dump", .{jni.asCStr(&s_files_dir)}); + + _ = jni.setenv("BINDIR", bindir, 1); + _ = jni.setenv("ROOTDIR", otp_root, 1); + _ = jni.setenv("PROGNAME", "erl", 1); + _ = jni.setenv("EMU", "beam", 1); + _ = jni.setenv("HOME", jni.asCStr(&s_files_dir), 1); + _ = jni.setenv("MOB_DATA_DIR", jni.asCStr(&s_files_dir), 1); + + // MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed. + // + // Problem: Ecto.Migrator uses :code.priv_dir(app) to locate migration .exs + // files. :code.priv_dir/1 works by looking up the app's OTP lib structure + // ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps are deployed to a flat -pa + // directory (e.g. files/otp/my_app/*.beam), not an OTP lib structure, so + // :code.priv_dir/1 returns {error, bad_name} and Ecto silently reports + // "Migrations already up" without running anything. + // + // Fix: deployer.ex pushes priv/ alongside the BEAMs into beams_dir/priv/. + // App code reads MOB_BEAMS_DIR at startup and passes the explicit path to + // Ecto.Migrator.run/4 instead of relying on :code.priv_dir/1. This env var + // is the only reliable way to communicate beams_dir to Elixir code since it + // is computed here from getFilesDir() at runtime (the path includes the + // Android user ID which is not predictable at compile time). + _ = jni.setenv("MOB_BEAMS_DIR", beams_dir, 1); + _ = jni.setenv("ERL_CRASH_DUMP", crash_dump, 1); + _ = jni.setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); + + var eval_expr_buf: [280]u8 = undefined; + const eval_expr = formatZ(&eval_expr_buf, "{s}:start().", .{app_module}); + + // Compile-time default BEAM tuning flags. Selected by build_options.beam_flags_mode + // (untuned / sbwt_only / nerves_full). Runtime override below wins if present. + const default_flags: []const [*:0]const u8 = comptime selectDefaultFlags(); + + // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. + // Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`. + { + var flags_path_buf: [640]u8 = undefined; + const flags_path = formatZ(&flags_path_buf, "{s}/mob_beam_flags", .{beams_dir}); + if (jni.fopen(flags_path, "r")) |fp| { + const n_read = jni.fread(&s_flags_buf, 1, s_flags_buf.len - 1, fp); + _ = jni.fclose(fp); + s_flags_buf[n_read] = 0; + s_runtime_flag_count = 0; + var p: usize = 0; + while (p < n_read and s_runtime_flag_count < 63) { + while (p < n_read and isWhitespace(s_flags_buf[p])) : (p += 1) {} + if (p >= n_read or s_flags_buf[p] == 0) break; + s_runtime_flags[s_runtime_flag_count] = @ptrCast(&s_flags_buf[p]); + s_runtime_flag_count += 1; + while (p < n_read and !isWhitespace(s_flags_buf[p]) and s_flags_buf[p] != 0) : (p += 1) {} + if (p < n_read) { + s_flags_buf[p] = 0; + p += 1; + } + } + s_runtime_flags[s_runtime_flag_count] = null; + logi("mob_start_beam: loaded {d} runtime flags from {s}", .{ s_runtime_flag_count, flags_path }); + } + } + + var boot_path_buf: [580]u8 = undefined; + const boot_path = formatZ(&boot_path_buf, "{s}/releases/29/start_clean", .{otp_root}); + + var args: [128]?[*:0]const u8 = @splat(null); + var ac: usize = 0; + args[ac] = "beam"; + ac += 1; + if (s_runtime_flag_count > 0) { + var i: usize = 0; + while (i < s_runtime_flag_count) : (i += 1) { + args[ac] = s_runtime_flags[i]; + ac += 1; + } + } else { + for (default_flags) |f| { + args[ac] = f; + ac += 1; + } + } + args[ac] = "--"; + ac += 1; + args[ac] = "-root"; + ac += 1; + args[ac] = otp_root; + ac += 1; + args[ac] = "-bindir"; + ac += 1; + args[ac] = bindir; + ac += 1; + args[ac] = "-progname"; + ac += 1; + args[ac] = "erl"; + ac += 1; + args[ac] = "--"; + ac += 1; + args[ac] = "-noshell"; + ac += 1; + args[ac] = "-noinput"; + ac += 1; + args[ac] = "-boot"; + ac += 1; + args[ac] = boot_path; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = elixir_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = logger_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = eex_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = beams_dir; + ac += 1; + args[ac] = "-eval"; + ac += 1; + args[ac] = eval_expr; + ac += 1; + args[ac] = null; + + // ── Cold-start race condition fix ──────────────────────────────────────── + // + // DO NOT REMOVE THIS BLOCK. + // + // Problem: on a cold start (first launch after install or after the process + // was killed), calling erl_start() too early causes a SIGABRT deep inside + // ERTS pthread initialisation. The crash looks like: + // + // FORTIFY: pthread_mutex_lock called on a destroyed mutex + // backtrace: + // #00 abort + // #01 pthread_mutex_lock (FORTIFY wrapper) + // #02 ... (ERTS internal thread pool setup) + // #03 erl_start + // + // Root cause: Android's hwui (hardware-accelerated UI renderer) creates its + // own native thread pool during the very first layout/draw pass. That + // initialisation uses pthread mutexes that it allocates and later destroys. + // ERTS also calls into pthreads during erl_start(). If erl_start() runs + // concurrently with hwui's first-draw setup, the two pthread paths race on + // the same internal libc state and the FORTIFY mutex check fires → SIGABRT. + // + // The race only reproduces on cold start because: + // • On warm start hwui's thread pool already exists → no race. + // • The window-focus event is the earliest point at which Android + // guarantees the first layout/draw pass has completed, so hwui's + // pthread state is stable. + // + // Fix: poll Activity.hasWindowFocus() every 50 ms before calling erl_start(). + // hasWindowFocus() returns true only after the window has been drawn and + // given input focus, which is *after* hwui finishes its thread-pool setup. + // We wait up to 3 seconds (covers slow emulators and heavily loaded devices) + // and fall through anyway so a stuck window never blocks BEAM forever. + // + // Why this lives here instead of in MainActivity.kt: + // Putting the delay in Kotlin would mean every app built on Mob needs to + // replicate and maintain the fix. Centralising it in mob_beam.zig means + // app code can stay a simple `Thread({ nativeStartBeam() }).start()`. + // + // JNI threading notes: + // • beam-main is created via `new Thread()` in Kotlin, so it is already + // attached to the JVM when this function runs. Calling + // AttachCurrentThread on an already-attached thread is a no-op, but + // calling DetachCurrentThread on a Java-created thread makes ART abort. + // • We therefore call GetEnv first. If the thread is already attached + // (needs_detach == 0) we skip both Attach and Detach. Only a purely + // native thread that was never attached would set needs_detach == 1. + if (g_jvm) |jvm| { + if (g_activity != null) { + mob_set_startup_phase("Waiting for window focus…"); + + const existing = jni.getEnv(jvm, jni.JNI_VERSION_1_6); + const needs_detach = existing == null; + const env2_maybe: ?*jni.JNIEnv = existing orelse jni.attachCurrentThread(jvm); + + if (env2_maybe) |env2| { + const act_cls = jni.getObjectClass(env2, g_activity); + const has_focus = jni.getMethodID(env2, act_cls, "hasWindowFocus", "()Z"); + var waited: i32 = 0; + const max_wait: i32 = 3000; // ms — fall through if focus never arrives + while (jni.callBooleanMethod(env2, g_activity, has_focus) == 0 and waited < max_wait) { + const ts = jni.Timespec{ .tv_sec = 0, .tv_nsec = 50_000_000 }; // 50 ms + _ = jni.nanosleep(&ts, null); + waited += 50; + } + // Only detach if we attached above — detaching a Java thread aborts ART. + if (needs_detach) jni.detachCurrentThread(jvm); + if (waited >= max_wait) { + logi("mob_start_beam: focus timeout ({d} ms) — starting BEAM anyway", .{waited}); + } else if (waited > 0) { + logi("mob_start_beam: waited {d} ms for window focus", .{waited}); + } + } else { + loge("mob_start_beam: AttachCurrentThread failed — skipping focus wait", .{}); + } + } + } + // ── end cold-start race condition fix ──────────────────────────────────── + + mob_set_startup_phase("Starting BEAM…"); + logi("mob_start_beam: starting BEAM with module={s}, argc={d}", .{ app_module, ac }); + + // Symlink ERTS executables from BINDIR to the native lib dir. + // + // When installed via `adb install`, nativeLibraryDir contains the .so files + // and the symlink approach works (apk_data_file SELinux label allows execve). + // + // When installed via Play Store (split APKs), Android does NOT extract .so + // files to nativeLibraryDir on modern devices — they stay inside the split APK + // zip. In that case MobBridge.extractBeamHelpersFromSplitApk() copies the + // binaries directly into erts/bin/ before this point. We detect that scenario + // by checking whether the nativeLibDir target exists: if it doesn't, skip the + // unlink+symlink so we don't clobber the already-extracted real file. + if (s_native_lib_dir[0] != 0) { + const exes = [_][*:0]const u8{ "erl_child_setup", "inet_gethost", "epmd" }; + const libs = [_][*:0]const u8{ "liberl_child_setup.so", "libinet_gethost.so", "libepmd.so" }; + var i: usize = 0; + while (i < exes.len) : (i += 1) { + var bin_path_buf: [512]u8 = undefined; + var lib_path_buf: [512]u8 = undefined; + const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, exes[i] }); + const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), libs[i] }); + var st: jni.Stat = undefined; + if (jni.stat(lib_path, &st) == 0) { + // nativeLibDir has the file (adb install) — use symlink + _ = jni.unlink(bin_path); + if (jni.symlink(lib_path, bin_path) == 0) { + logi("mob_start_beam: symlink {s} -> {s}", .{ exes[i], lib_path }); + } else { + loge("mob_start_beam: symlink {s} failed: {s}", .{ exes[i], lastErrno() }); + } + } else { + // nativeLibDir empty (Play Store split APK) — MobBridge should have + // extracted the binary directly to bin_path; leave it in place. + var st_bin: jni.Stat = undefined; + if (jni.stat(bin_path, &st_bin) == 0) { + logi("mob_start_beam: symlink {s} (extracted from split APK)", .{exes[i]}); + } else { + loge("mob_start_beam: symlink {s} missing from both nativeLibDir and bin/", .{exes[i]}); + } + } + } + } + + // Symlink sqlite3_nif.so into the exqlite OTP lib structure so that + // code:priv_dir(:exqlite) resolves correctly. + // + // The OTP code server registers lib_dirs by scanning $OTP_ROOT/lib/*/ebin + // at boot. For code:lib_dir(:exqlite) to work, exqlite must live at + // $OTP_ROOT/lib/exqlite-VERSION/ — a flat -pa dir is NOT sufficient. + // The deployer creates $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv}; we + // create the sqlite3_nif.so symlink inside priv/ at runtime so the path + // (which contains the APK install hash) is always up-to-date. + if (s_native_lib_dir[0] != 0) { + var nif_target_buf: [560]u8 = undefined; + const nif_target = formatZ(&nif_target_buf, "{s}/libsqlite3_nif.so", .{jni.asCStr(&s_native_lib_dir)}); + + // Scan $OTP_ROOT/lib/ for exqlite-* and symlink the NIF in its priv/. + var lib_path_buf: [600]u8 = undefined; + const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root}); + var found = false; + if (jni.opendir(lib_path)) |d| { + while (jni.readdir(d)) |entry| { + if (jni.strncmp(@ptrCast(&entry.d_name), "exqlite-", 8) == 0) { + var exqlite_priv_buf: [700]u8 = undefined; + const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name); + const exqlite_priv = formatZ(&exqlite_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c }); + _ = jni.mkdir(exqlite_priv, 0o755); + var nif_link_buf: [760]u8 = undefined; + const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{exqlite_priv}); + var st_nif: jni.Stat = undefined; + if (jni.stat(nif_target, &st_nif) == 0) { + // nativeLibDir has the NIF (adb install) — use symlink + _ = jni.unlink(nif_link); + if (jni.symlink(nif_target, nif_link) == 0) { + logi("mob_start_beam: symlink exqlite NIF -> {s}", .{nif_target}); + found = true; + } else { + loge("mob_start_beam: symlink exqlite NIF failed: {s}", .{lastErrno()}); + } + } else { + // nativeLibDir empty — MobBridge extracted NIF directly to nif_link + var st_nif_file: jni.Stat = undefined; + if (jni.stat(nif_link, &st_nif_file) == 0) { + logi("mob_start_beam: exqlite NIF extracted from split APK", .{}); + found = true; + } else { + loge("mob_start_beam: exqlite NIF missing from both nativeLibDir and priv/", .{}); + } + } + break; + } + } + _ = jni.closedir(d); + } + + if (!found) { + // Fallback: symlink into flat beams_dir/priv/ for backward compatibility + // while the deployer hasn't yet created the versioned lib structure. + var priv_dir_buf: [660]u8 = undefined; + const priv_dir = formatZ(&priv_dir_buf, "{s}/priv", .{beams_dir}); + _ = jni.mkdir(priv_dir, 0o755); + var nif_link_buf: [720]u8 = undefined; + const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{priv_dir}); + var st_nif_fb: jni.Stat = undefined; + if (jni.stat(nif_target, &st_nif_fb) == 0) { + _ = jni.unlink(nif_link); + if (jni.symlink(nif_target, nif_link) == 0) { + logi("mob_start_beam: symlink sqlite3_nif.so (fallback) -> {s}", .{nif_target}); + } else { + loge("mob_start_beam: symlink sqlite3_nif (fallback) failed: {s}", .{lastErrno()}); + } + } else { + var st_fb_file: jni.Stat = undefined; + if (jni.stat(nif_link, &st_fb_file) == 0) { + logi("mob_start_beam: sqlite3_nif.so (fallback) extracted from split APK", .{}); + } else { + loge("mob_start_beam: sqlite3_nif.so (fallback) missing — NIF load will fail", .{}); + } + } + } + } + + // Symlink libpythonx.so into the pythonx OTP lib structure for the + // same reason as exqlite above. Pythonx's NIF on_load does + // path = :filename.join(:code.priv_dir(:pythonx), 'libpythonx') + // :erlang.load_nif(path, 0) + // For dlopen to resolve enif_* (defined in the main app native lib) + // the .so has to live in the app's namespace — i.e. nativeLibraryDir. + // mob_dev's NativeBuild already places libpythonx.so in jniLibs, so + // the APK installer extracts it to nativeLibraryDir at install time. + // We just symlink into the OTP lib priv/ to make :code.priv_dir + // return a path that dlopen can follow. + if (s_native_lib_dir[0] != 0) { + var pyx_target_buf: [560]u8 = undefined; + const pyx_target = formatZ(&pyx_target_buf, "{s}/libpythonx.so", .{jni.asCStr(&s_native_lib_dir)}); + + var st_pyx: jni.Stat = undefined; + if (jni.stat(pyx_target, &st_pyx) == 0) { + var lib_path_buf: [600]u8 = undefined; + const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root}); + if (jni.opendir(lib_path)) |d2| { + while (jni.readdir(d2)) |entry| { + if (jni.strncmp(@ptrCast(&entry.d_name), "pythonx-", 8) == 0) { + var pyx_priv_buf: [700]u8 = undefined; + const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name); + const pyx_priv = formatZ(&pyx_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c }); + _ = jni.mkdir(pyx_priv, 0o755); + var pyx_link_buf: [760]u8 = undefined; + const pyx_link = formatZ(&pyx_link_buf, "{s}/libpythonx.so", .{pyx_priv}); + _ = jni.unlink(pyx_link); + if (jni.symlink(pyx_target, pyx_link) == 0) { + logi("mob_start_beam: symlink pythonx NIF -> {s}", .{pyx_target}); + } else { + loge("mob_start_beam: symlink pythonx NIF failed: {s}", .{lastErrno()}); + } + break; + } + } + _ = jni.closedir(d2); + } + } + } + + // erl_start blocks forever in the normal case. If it returns at all the + // BEAM has exited unexpectedly — report it to the UI and let logcat carry + // the details. The caller's caller (Java thread) will reap the process. + erl_start(@intCast(ac), @ptrCast(&args)); + mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details"); + loge("mob_start_beam: erl_start returned (unexpected)", .{}); +} + +// ── Comptime helpers ────────────────────────────────────────────────────── + +fn selectDefaultFlags() []const [*:0]const u8 { + // String comparison at comptime — build_options.beam_flags_mode is a + // []const u8 baked into the binary at build time. + if (std.mem.eql(u8, BEAM_FLAGS_MODE, "untuned")) { + return &.{}; + } + if (std.mem.eql(u8, BEAM_FLAGS_MODE, "sbwt_only")) { + return &.{ + "-sbwt", "none", + "-sbwtdcpu", "none", + "-sbwtdio", "none", + }; + } + // Default: full Nerves-style tuning. + return &.{ + "-S", "1:1", + "-SDcpu", "1:1", + "-SDio", "1", + "-A", "1", + "-sbwt", "none", + "-sbwtdcpu", "none", + "-sbwtdio", "none", + }; +} diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig new file mode 100644 index 00000000..8a045076 --- /dev/null +++ b/android/jni/mob_zig.zig @@ -0,0 +1,470 @@ +//! mob_zig.zig — Hand-declared JNI/Android/libc bindings for Mob's Zig code. +//! +//! Phase 6b of the build-system migration translates mob's Android C source +//! (mob_beam.c, mob_nif.c) to Zig. Zig 0.17-dev's `@cImport` builtin was +//! removed and `zig translate-c` hangs on the Android NDK's `jni.h` (deep +//! recursive include tree). Hand-declaring the FFI surface sidesteps both: +//! +//! * **Stable**: JNI ABI hasn't materially changed since Java 1.1 (1997). +//! Android log + libc surface used here is similarly stable. +//! * **Minimal**: declares only what Mob's Zig source actually uses. +//! ~250 lines beats a thousand-line auto-generated translation. +//! * **Auditable**: a reviewer can read the whole binding in one sitting. +//! * **Future-proof**: doesn't depend on Zig version's @cImport behavior. +//! +//! The hand-declared layouts mirror the C headers byte-for-byte (verified +//! against AOSP's `frameworks/native/include/jni.h` and Android NDK's +//! `android/log.h`, `dlfcn.h`, etc.). + +const std = @import("std"); + +// ── Android log ──────────────────────────────────────────────────────────── + +pub const ANDROID_LOG_VERBOSE: c_int = 2; +pub const ANDROID_LOG_DEBUG: c_int = 3; +pub const ANDROID_LOG_INFO: c_int = 4; +pub const ANDROID_LOG_WARN: c_int = 5; +pub const ANDROID_LOG_ERROR: c_int = 6; + +pub extern fn __android_log_write(prio: c_int, tag: [*:0]const u8, text: [*:0]const u8) c_int; +pub extern fn __android_log_print(prio: c_int, tag: [*:0]const u8, fmt: [*:0]const u8, ...) c_int; + +/// Format a message with std.fmt and write it via __android_log_write. +/// Truncates safely on oversize input (Android log already truncates at +/// ~4 KB anyway). +pub fn logWrite(prio: c_int, comptime tag: [*:0]const u8, comptime fmt: []const u8, args: anytype) void { + var buf: [4096]u8 = undefined; + const slice = std.fmt.bufPrint(&buf, fmt, args) catch buf[0..(buf.len - 1)]; + // bufPrint doesn't NUL-terminate; we need NUL for __android_log_write. + const end = @min(slice.len, buf.len - 1); + buf[end] = 0; + _ = __android_log_write(prio, tag, buf[0..end :0]); +} + +// ── POSIX / libc ─────────────────────────────────────────────────────────── + +pub const STDOUT_FILENO: c_int = 1; +pub const STDERR_FILENO: c_int = 2; + +pub extern fn pipe(fds: *[2]c_int) c_int; +pub extern fn dup2(oldfd: c_int, newfd: c_int) c_int; +pub extern fn close(fd: c_int) c_int; +pub extern fn read(fd: c_int, buf: [*]u8, count: usize) isize; +pub extern fn setvbuf(stream: *FILE, buf: ?[*]u8, mode: c_int, size: usize) c_int; +pub extern fn fopen(pathname: [*:0]const u8, mode: [*:0]const u8) ?*FILE; +pub extern fn fread(ptr: [*]u8, size: usize, nmemb: usize, stream: *FILE) usize; +pub extern fn fclose(stream: *FILE) c_int; +/// bionic's errno getter. The C `errno` macro expands to `(*__errno())`. +/// Symbol name matches the linker name in libc.so (`__errno`, not +/// `__errno_location` — that's the glibc spelling). +pub extern fn __errno() *c_int; +pub extern fn strerror(errnum: c_int) [*:0]const u8; +pub extern fn strncmp(s1: [*]const u8, s2: [*]const u8, n: usize) c_int; +pub extern fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +pub extern fn mkdir(pathname: [*:0]const u8, mode: u32) c_int; +pub extern fn unlink(pathname: [*:0]const u8) c_int; +pub extern fn symlink(target: [*:0]const u8, linkpath: [*:0]const u8) c_int; +pub extern fn stat(pathname: [*:0]const u8, statbuf: *Stat) c_int; +pub extern fn opendir(name: [*:0]const u8) ?*DIR; +pub extern fn readdir(dirp: *DIR) ?*Dirent; +pub extern fn closedir(dirp: *DIR) c_int; +pub extern fn nanosleep(req: *const Timespec, rem: ?*Timespec) c_int; +pub extern fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) c_int; + +pub const _IONBF: c_int = 2; + +pub const FILE = opaque {}; + +/// bionic exposes `stdout` and `stderr` as `extern FILE*` symbols (NDK 23+, +/// API ≥ 21). We use them only to call `setvbuf(stdout, NULL, _IONBF, 0)` +/// after redirecting fd 1/2 to a pipe — the libc-side FILE objects retain +/// their own buffer until told otherwise. +pub extern var stdout: *FILE; +pub extern var stderr: *FILE; + +/// Opaque DIR for opendir/readdir/closedir. +pub const DIR = opaque {}; + +/// Android bionic dirent layout (sufficient for us — only need d_name). +/// AOSP source: bionic/libc/include/dirent.h. +pub const Dirent = extern struct { + d_ino: u64, + d_off: i64, + d_reclen: u16, + d_type: u8, + d_name: [256]u8, +}; + +pub const Stat = extern struct { + // Layout we don't fully care about — we only call stat() for existence + // check. Opaque-sized buffer is safer than getting field offsets wrong. + _opaque: [256]u8, +}; + +pub const Timespec = extern struct { + tv_sec: i64, + tv_nsec: i64, +}; + +pub extern fn pthread_create( + thread: *PthreadT, + attr: ?*const anyopaque, + start_routine: *const fn (?*anyopaque) callconv(.c) ?*anyopaque, + arg: ?*anyopaque, +) c_int; + +pub extern fn pthread_detach(thread: PthreadT) c_int; + +pub const PthreadT = usize; // Android: pthread_t is a long unsigned int + +// ── dlfcn ────────────────────────────────────────────────────────────────── + +pub const RTLD_NOW: c_int = 2; +pub const RTLD_GLOBAL: c_int = 0x00100; + +pub extern fn dlopen(filename: [*:0]const u8, flags: c_int) ?*anyopaque; +pub extern fn dlerror() ?[*:0]const u8; + +// ── JNI ──────────────────────────────────────────────────────────────────── +// AOSP source: frameworks/native/include/jni.h. We only declare the vtable +// entries we actually call; future iters can add more as needed. + +pub const JNI_VERSION_1_6: c_int = 0x00010006; +pub const JNI_OK: c_int = 0; + +pub const JBoolean = u8; +pub const JInt = i32; +pub const JLong = i64; +pub const JFloat = f32; +pub const JDouble = f64; + +pub const JObject = ?*anyopaque; +pub const JClass = JObject; +pub const JString = JObject; +pub const JFieldID = ?*anyopaque; +pub const JMethodID = ?*anyopaque; + +/// JNIEnv is a pointer-to-pointer-to-JNINativeInterface. C usage: +/// `(*env)->FindClass(env, "..")` +/// Zig usage via our helpers: +/// `jni.findClass(env, "..")` +pub const JNIEnv = *const JNINativeInterface; + +/// Vtable inside JNIEnv. Order matters — must match jni.h exactly. +/// We declare only the slots we use, plus reserved padding for the rest. +/// Each `?*const fn(...) callconv(.c) ...` is a function pointer. +pub const JNINativeInterface = extern struct { + _reserved0: ?*anyopaque, + _reserved1: ?*anyopaque, + _reserved2: ?*anyopaque, + _reserved3: ?*anyopaque, + + // Index 4: GetVersion — unused but in the slot order. + GetVersion: ?*const fn (env: *JNIEnv) callconv(.c) JInt, + + // 5-8: DefineClass, FindClass, FromReflectedMethod, FromReflectedField + DefineClass: ?*anyopaque, + FindClass: ?*const fn (env: *JNIEnv, name: [*:0]const u8) callconv(.c) JClass, + FromReflectedMethod: ?*anyopaque, + FromReflectedField: ?*anyopaque, + + // 9-16: reflected/IsAssignableFrom + exceptions block + ToReflectedMethod: ?*anyopaque, + GetSuperclass: ?*anyopaque, + IsAssignableFrom: ?*anyopaque, + ToReflectedField: ?*anyopaque, + Throw: ?*anyopaque, + ThrowNew: ?*anyopaque, + ExceptionOccurred: ?*anyopaque, + ExceptionDescribe: ?*anyopaque, + + // 17-22: exception finish, refs + ExceptionClear: ?*anyopaque, + FatalError: ?*anyopaque, + PushLocalFrame: ?*anyopaque, + PopLocalFrame: ?*anyopaque, + NewGlobalRef: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) JObject, + DeleteGlobalRef: ?*const fn (env: *JNIEnv, gref: JObject) callconv(.c) void, + + // 23-26: local ref slots + DeleteLocalRef: ?*anyopaque, + IsSameObject: ?*anyopaque, + NewLocalRef: ?*anyopaque, + EnsureLocalCapacity: ?*anyopaque, + + // 27-29: object creation + AllocObject: ?*anyopaque, + NewObject: ?*anyopaque, + NewObjectV: ?*anyopaque, + + // 30-32: object type queries + NewObjectA: ?*anyopaque, + GetObjectClass: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) JClass, + IsInstanceOf: ?*anyopaque, + + // 33: GetMethodID + GetMethodID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JMethodID, + + // 34-60: many CallXxxMethod variants — we only use CallObjectMethod + // and CallBooleanMethod by typed signature. Pad as opaque. + CallObjectMethod: ?*const fn (env: *JNIEnv, obj: JObject, mid: JMethodID, ...) callconv(.c) JObject, + CallObjectMethodV: ?*anyopaque, + CallObjectMethodA: ?*anyopaque, + CallBooleanMethod: ?*const fn (env: *JNIEnv, obj: JObject, mid: JMethodID, ...) callconv(.c) JBoolean, + CallBooleanMethodV: ?*anyopaque, + CallBooleanMethodA: ?*anyopaque, + CallByteMethod: ?*anyopaque, + CallByteMethodV: ?*anyopaque, + CallByteMethodA: ?*anyopaque, + CallCharMethod: ?*anyopaque, + CallCharMethodV: ?*anyopaque, + CallCharMethodA: ?*anyopaque, + CallShortMethod: ?*anyopaque, + CallShortMethodV: ?*anyopaque, + CallShortMethodA: ?*anyopaque, + CallIntMethod: ?*anyopaque, + CallIntMethodV: ?*anyopaque, + CallIntMethodA: ?*anyopaque, + CallLongMethod: ?*anyopaque, + CallLongMethodV: ?*anyopaque, + CallLongMethodA: ?*anyopaque, + CallFloatMethod: ?*anyopaque, + CallFloatMethodV: ?*anyopaque, + CallFloatMethodA: ?*anyopaque, + CallDoubleMethod: ?*anyopaque, + CallDoubleMethodV: ?*anyopaque, + CallDoubleMethodA: ?*anyopaque, + CallVoidMethod: ?*anyopaque, + CallVoidMethodV: ?*anyopaque, + CallVoidMethodA: ?*anyopaque, + + // 62-94: nonvirtual call variants + field accessors + CallNonvirtualObjectMethod: ?*anyopaque, + CallNonvirtualObjectMethodV: ?*anyopaque, + CallNonvirtualObjectMethodA: ?*anyopaque, + CallNonvirtualBooleanMethod: ?*anyopaque, + CallNonvirtualBooleanMethodV: ?*anyopaque, + CallNonvirtualBooleanMethodA: ?*anyopaque, + CallNonvirtualByteMethod: ?*anyopaque, + CallNonvirtualByteMethodV: ?*anyopaque, + CallNonvirtualByteMethodA: ?*anyopaque, + CallNonvirtualCharMethod: ?*anyopaque, + CallNonvirtualCharMethodV: ?*anyopaque, + CallNonvirtualCharMethodA: ?*anyopaque, + CallNonvirtualShortMethod: ?*anyopaque, + CallNonvirtualShortMethodV: ?*anyopaque, + CallNonvirtualShortMethodA: ?*anyopaque, + CallNonvirtualIntMethod: ?*anyopaque, + CallNonvirtualIntMethodV: ?*anyopaque, + CallNonvirtualIntMethodA: ?*anyopaque, + CallNonvirtualLongMethod: ?*anyopaque, + CallNonvirtualLongMethodV: ?*anyopaque, + CallNonvirtualLongMethodA: ?*anyopaque, + CallNonvirtualFloatMethod: ?*anyopaque, + CallNonvirtualFloatMethodV: ?*anyopaque, + CallNonvirtualFloatMethodA: ?*anyopaque, + CallNonvirtualDoubleMethod: ?*anyopaque, + CallNonvirtualDoubleMethodV: ?*anyopaque, + CallNonvirtualDoubleMethodA: ?*anyopaque, + CallNonvirtualVoidMethod: ?*anyopaque, + CallNonvirtualVoidMethodV: ?*anyopaque, + CallNonvirtualVoidMethodA: ?*anyopaque, + + // 95: GetFieldID — we use this + GetFieldID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JFieldID, + + // 96-104: GetXxxField — we use GetObjectField + GetObjectField: ?*const fn (env: *JNIEnv, obj: JObject, fid: JFieldID) callconv(.c) JObject, + GetBooleanField: ?*anyopaque, + GetByteField: ?*anyopaque, + GetCharField: ?*anyopaque, + GetShortField: ?*anyopaque, + GetIntField: ?*anyopaque, + GetLongField: ?*anyopaque, + GetFloatField: ?*anyopaque, + GetDoubleField: ?*anyopaque, + + // 105-113: SetXxxField + static method id/calls — unused + SetObjectField: ?*anyopaque, + SetBooleanField: ?*anyopaque, + SetByteField: ?*anyopaque, + SetCharField: ?*anyopaque, + SetShortField: ?*anyopaque, + SetIntField: ?*anyopaque, + SetLongField: ?*anyopaque, + SetFloatField: ?*anyopaque, + SetDoubleField: ?*anyopaque, + + // 114-152: static stuff + string ops — pad as opaque, we don't use them + // in mob_beam.zig (mob_nif iters will likely need GetStaticMethodID etc.). + GetStaticMethodID: ?*anyopaque, + CallStaticObjectMethod: ?*anyopaque, + CallStaticObjectMethodV: ?*anyopaque, + CallStaticObjectMethodA: ?*anyopaque, + CallStaticBooleanMethod: ?*anyopaque, + CallStaticBooleanMethodV: ?*anyopaque, + CallStaticBooleanMethodA: ?*anyopaque, + CallStaticByteMethod: ?*anyopaque, + CallStaticByteMethodV: ?*anyopaque, + CallStaticByteMethodA: ?*anyopaque, + CallStaticCharMethod: ?*anyopaque, + CallStaticCharMethodV: ?*anyopaque, + CallStaticCharMethodA: ?*anyopaque, + CallStaticShortMethod: ?*anyopaque, + CallStaticShortMethodV: ?*anyopaque, + CallStaticShortMethodA: ?*anyopaque, + CallStaticIntMethod: ?*anyopaque, + CallStaticIntMethodV: ?*anyopaque, + CallStaticIntMethodA: ?*anyopaque, + CallStaticLongMethod: ?*anyopaque, + CallStaticLongMethodV: ?*anyopaque, + CallStaticLongMethodA: ?*anyopaque, + CallStaticFloatMethod: ?*anyopaque, + CallStaticFloatMethodV: ?*anyopaque, + CallStaticFloatMethodA: ?*anyopaque, + CallStaticDoubleMethod: ?*anyopaque, + CallStaticDoubleMethodV: ?*anyopaque, + CallStaticDoubleMethodA: ?*anyopaque, + CallStaticVoidMethod: ?*anyopaque, + CallStaticVoidMethodV: ?*anyopaque, + CallStaticVoidMethodA: ?*anyopaque, + GetStaticFieldID: ?*anyopaque, + GetStaticObjectField: ?*anyopaque, + GetStaticBooleanField: ?*anyopaque, + GetStaticByteField: ?*anyopaque, + GetStaticCharField: ?*anyopaque, + GetStaticShortField: ?*anyopaque, + GetStaticIntField: ?*anyopaque, + GetStaticLongField: ?*anyopaque, + GetStaticFloatField: ?*anyopaque, + GetStaticDoubleField: ?*anyopaque, + + // 153-162: SetStaticXxxField — unused + SetStaticObjectField: ?*anyopaque, + SetStaticBooleanField: ?*anyopaque, + SetStaticByteField: ?*anyopaque, + SetStaticCharField: ?*anyopaque, + SetStaticShortField: ?*anyopaque, + SetStaticIntField: ?*anyopaque, + SetStaticLongField: ?*anyopaque, + SetStaticFloatField: ?*anyopaque, + SetStaticDoubleField: ?*anyopaque, + + // 163-168: NewString + GetStringChars — unused but pad for completeness + NewString: ?*anyopaque, + GetStringLength: ?*anyopaque, + GetStringChars: ?*anyopaque, + ReleaseStringChars: ?*anyopaque, + NewStringUTF: ?*anyopaque, + GetStringUTFLength: ?*anyopaque, + + // 169-170: GetStringUTFChars / ReleaseStringUTFChars — we use these + GetStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, is_copy: ?*JBoolean) callconv(.c) ?[*:0]const u8, + ReleaseStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, utf: [*:0]const u8) callconv(.c) void, + + // The remaining ~60 slots (array ops, monitor enter/exit, GetJavaVM, + // NewWeakGlobalRef, etc.) are not used by mob_beam.zig — add when an + // iter needs them. Leaving them out is fine because we never read past + // the declared slots: as long as the layout up to the last USED slot + // matches jni.h, the unused tail can be anything. +}; + +/// JavaVM vtable — used for GetEnv / AttachCurrentThread / DetachCurrentThread. +pub const JavaVM = *const JNIInvokeInterface; + +pub const JNIInvokeInterface = extern struct { + _reserved0: ?*anyopaque, + _reserved1: ?*anyopaque, + _reserved2: ?*anyopaque, + DestroyJavaVM: ?*anyopaque, + AttachCurrentThread: ?*const fn (vm: *JavaVM, env: *?*JNIEnv, args: ?*anyopaque) callconv(.c) JInt, + DetachCurrentThread: ?*const fn (vm: *JavaVM) callconv(.c) JInt, + GetEnv: ?*const fn (vm: *JavaVM, env: *?*anyopaque, version: JInt) callconv(.c) JInt, + AttachCurrentThreadAsDaemon: ?*anyopaque, +}; + +// ── Wrapper helpers (hide vtable indirection) ────────────────────────────── +// Each one-liner unwraps the JNIEnv vtable pointer and the function-pointer +// optional. Cuts call-site noise: `jni.findClass(env, "X")` vs +// `env.*.FindClass.?(env, "X")`. + +pub inline fn findClass(env: *JNIEnv, name: [*:0]const u8) JClass { + return env.*.FindClass.?(env, name); +} + +pub inline fn getObjectClass(env: *JNIEnv, obj: JObject) JClass { + return env.*.GetObjectClass.?(env, obj); +} + +pub inline fn getMethodID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JMethodID { + return env.*.GetMethodID.?(env, cls, name, sig); +} + +pub inline fn getFieldID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JFieldID { + return env.*.GetFieldID.?(env, cls, name, sig); +} + +pub inline fn callObjectMethod(env: *JNIEnv, obj: JObject, mid: JMethodID) JObject { + return env.*.CallObjectMethod.?(env, obj, mid); +} + +pub inline fn callBooleanMethod(env: *JNIEnv, obj: JObject, mid: JMethodID) JBoolean { + return env.*.CallBooleanMethod.?(env, obj, mid); +} + +pub inline fn getObjectField(env: *JNIEnv, obj: JObject, fid: JFieldID) JObject { + return env.*.GetObjectField.?(env, obj, fid); +} + +pub inline fn getStringUTFChars(env: *JNIEnv, str: JString) ?[*:0]const u8 { + return env.*.GetStringUTFChars.?(env, str, null); +} + +pub inline fn releaseStringUTFChars(env: *JNIEnv, str: JString, utf: [*:0]const u8) void { + env.*.ReleaseStringUTFChars.?(env, str, utf); +} + +pub inline fn newGlobalRef(env: *JNIEnv, obj: JObject) JObject { + return env.*.NewGlobalRef.?(env, obj); +} + +pub inline fn getEnv(vm: *JavaVM, version: JInt) ?*JNIEnv { + var env: ?*anyopaque = null; + if (vm.*.GetEnv.?(vm, &env, version) != JNI_OK) return null; + return @ptrCast(@alignCast(env)); +} + +pub inline fn attachCurrentThread(vm: *JavaVM) ?*JNIEnv { + var env: ?*JNIEnv = null; + if (vm.*.AttachCurrentThread.?(vm, &env, null) != JNI_OK) return null; + return env; +} + +pub inline fn detachCurrentThread(vm: *JavaVM) void { + _ = vm.*.DetachCurrentThread.?(vm); +} + +// ── Small string utilities ──────────────────────────────────────────────── + +/// Copy a NUL-terminated source string into a fixed-size buffer, truncating +/// (NUL-terminated) on overflow. Mirrors `snprintf(buf, sizeof(buf), "%s", src)`. +pub fn copyZ(buf: []u8, src: [*:0]const u8) void { + var i: usize = 0; + while (i < buf.len - 1 and src[i] != 0) : (i += 1) { + buf[i] = src[i]; + } + buf[i] = 0; +} + +/// Compute the NUL-terminated length of a buffer (i.e. C strlen of buf[..]). +pub fn zLen(buf: []const u8) usize { + var i: usize = 0; + while (i < buf.len and buf[i] != 0) : (i += 1) {} + return i; +} + +/// View a NUL-terminated buffer as a NUL-terminated [*:0]const u8. +/// The buffer must contain at least one NUL byte within its bounds. +pub fn asCStr(buf: []const u8) [*:0]const u8 { + return @ptrCast(buf.ptr); +} From 02ce28c5f936dd9a95342b089486813532fe5bf7 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 12:21:17 -0600 Subject: [PATCH 022/254] build_system_migration: Phase 6b iter 2 logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the mob_beam.c → mob_beam.zig port (full BEAM launcher), the hand-declared FFI binding module in android/jni/mob_zig.zig (stable JNI/libc/Android surface that's reusable for iter 3+ mob_nif.zig work), the comptime gates that replace #ifdef (no_beam, beam_flags_mode), and the verification path. Notes that full Android smoke testing is deferred to iter 3 prep so the next mob_nif.zig slice ships with a single end-to-end deploy test — at iter-2 granularity the object-link symbol check already proves the C-ABI surface is intact. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index 426bc732..f7814139 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1079,3 +1079,62 @@ something useful even if the total project pauses. (mob_beam.c → mob_beam.zig as iter 2; mob_nif.c → mob_nif.zig across several iters as iter 3+) starts from a known-working toolchain. + + - iter 2 (mob_beam.c → mob_beam.zig): full port of the Android + BEAM launcher (~540 lines). All load-bearing behaviour + preserved byte-for-byte: + • cold-start race fix (window-focus wait that prevents the + FORTIFY pthread_mutex SIGABRT against hwui's first-draw + setup — DO NOT REMOVE comment block kept verbatim) + • SELinux exec rules for ERTS bin symlinks + • Play Store split-APK fallback for exqlite/pythonx priv-dir + wiring + • BEAM stdio → logcat capture pipeline + + Foundation FFI bindings hand-declared in + `android/jni/mob_zig.zig` (~470 lines: JNI vtable, libc, + Android log, dlfcn, pthreads). Zig 0.17-dev's `@cImport` + builtin is gone and `zig translate-c` hangs at 99% CPU on + the Android NDK's `jni.h` (deep recursive include tree). + Hand-declaring sidesteps both. Surface is stable — JNI ABI + hasn't materially changed since Java 1.1 (1997). Reusable + for iter 3+ mob_nif.zig work; new vtable slots get added as + that surface needs them. + + Comptime gates replace `#ifdef`: + • `no_beam` — battery baseline config; default false + • `beam_flags_mode` — picks the default scheduler-tuning + argv shape ("untuned" / "sbwt_only" / "nerves_full"); + default "nerves_full". Runtime override file + (`beams_dir/mob_beam_flags`, written by + `mix mob.deploy --schedulers N`) still wins. + + Threaded via `b.addOptions()` from the per-app Android + `build.zig.eex` template (companion mob_new commit). The + `addZigObject` helper already accepted `?*Step.Options` from + iter 1, so wiring was four lines in the source-iteration + loop. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean object. Exported + symbols (`mob_init_bridge`, `mob_start_beam`, + `mob_ui_cache_class`) match the C surface; + undefined references match what mob_nif.c provides + (`g_jvm`, `g_activity`, `_mob_ui_cache_class_impl`, + `_mob_bridge_init_activity`, `mob_set_startup_phase`, + `mob_set_startup_error`) plus libbeam's `erl_start` plus + standard bionic / libdl / liblog. `mob_beam.c` deleted; + `mob_beam.h` retained (still included by per-app + `beam_jni.c`). Full Android smoke test (mix mob.deploy + --native against an emulator) deferred to iter 3 prep so + the BEAM launcher + the next mob_nif.zig slice ship + together — the build chain is verified at object-link + granularity here. + + - iter 3+ (mob_nif.c → mob_nif.zig): ~2570 lines, 79 NIF + functions. Will land across several iters, grouped by NIF + family (UI/render, gesture senders, device capabilities, + WebView, alerts, color-scheme, etc.). The mob_zig.zig FFI + binding module from iter 2 covers the JNI surface today; + additional CallStaticXxxMethod / array-op vtable slots get + added as each iter needs them. From 04b8f411e2c18ac091096c3685bcccd956eb1b47 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 13:27:20 -0600 Subject: [PATCH 023/254] =?UTF-8?q?Phase=206b=20iter=203a=20(mob=20side):?= =?UTF-8?q?=20begin=20mob=5Fnif.c=20=E2=86=92=20mob=5Fnif.zig=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inaugural slice of the multi-iter mob_nif port. Phase 6b iter 3 is sized into 4 sub-iters because mob_nif.c (2570 lines, 79 NIFs) has tight internal coupling between cached MobBridge method IDs, handle registries, mutexes, senders, and the NIF dispatch table. Each sub-iter ships a working build. iter 3a establishes the cross-language linkage pattern: * NEW android/jni/mob_erts.zig — hand-declared ERL_NIF FFI surface (ERL_NIF_TERM, ErlNifEnv, ErlNifPid, ErlNifMutex, ErlNifBinary, ErlNifFunc, ErlNifCharEncoding + enif_make_atom / enif_make_int / enif_make_double / enif_make_badarg / enif_make_binary / enif_make_string / enif_inspect_binary / enif_get_string / enif_get_atom). Companion to mob_zig.zig — same rationale (Zig 0.17-dev has no @cImport; translate-c is unreliable; the surface is small + stable enough to hand-declare auditably). Later iters extend this file as their NIFs require more enif_* coverage. * NEW android/jni/mob_nif.zig — exports nif_platform/0, nif_log/1, nif_log/2 (plus the atomToAndroidPriority helper that only log/2 needed). Behaviour byte-for-byte equivalent: same atom names, same priority mapping (:debug/:info/:warning/:error → Android log levels; unknown → INFO), same 4 KB buffer truncation, same binary + charlist accept path. * mob_nif.c — three NIF function definitions removed; gap replaced with a load-bearing comment block. The static ErlNifFunc nif_funcs[] table still references nif_platform / nif_log / nif_log2 — they're now resolved at link time from the Zig exports via an `extern ERL_NIF_TERM ...` block near the top of the file. The list there grows as each later sub-iter ports more functions; when iter 3d moves the table itself, the externs disappear with the C file. Verified: standalone `zig build-obj -target aarch64-linux-android.24` produces a clean mob_nif.o that exports `nif_platform`, `nif_log`, `nif_log2`. Undefined references resolve cleanly against libbeam (enif_*) and liblog (__android_log_print). mob_nif.c passes clang-format. Full Android smoke deploy deferred to iter 3 prep so the next slice (iter 3b — test harness) ships with a single end-to-end deploy. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_erts.zig | 116 +++++++++++++++++++++++++++++++++++++++ android/jni/mob_nif.c | 67 ++++++---------------- android/jni/mob_nif.zig | 112 +++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 51 deletions(-) create mode 100644 android/jni/mob_erts.zig create mode 100644 android/jni/mob_nif.zig diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig new file mode 100644 index 00000000..515541aa --- /dev/null +++ b/android/jni/mob_erts.zig @@ -0,0 +1,116 @@ +//! mob_erts.zig — Hand-declared FFI bindings for the BEAM's ERL_NIF surface. +//! +//! Companion to mob_zig.zig (which covers JNI / libc / Android log). This +//! module narrows in on the symbols that NIF authors call when writing +//! against `erl_nif.h`. We hand-declare what we use rather than @cImport'ing +//! erl_nif.h for the same reasons documented at the top of mob_zig.zig: +//! Zig 0.17-dev's @cImport is gone, translate-c is unreliable on deeply +//! nested headers, and the surface is small + stable so an auditable +//! hand declaration is easy to maintain. +//! +//! Phase 6b iter 3a introduces this file. It declares only what iter 3a's +//! NIFs (nif_platform, nif_log, nif_log2) need; later iters extend it as +//! their ported NIFs require more of the ERL_NIF surface. +//! +//! Authoritative reference: OTP 27+ `erl_nif.h` and `erl_nif_api_funcs.h`. + +const std = @import("std"); + +// ── Core types ───────────────────────────────────────────────────────────── + +/// ERL_NIF_TERM is `ErlNifUInt`, which is `unsigned long` on every platform +/// where BEAM is supported. c_ulong matches that and stays 64-bit on +/// aarch64-android (LP64), which is what we ship. +pub const ERL_NIF_TERM = c_ulong; + +/// Opaque from the user's perspective — the BEAM owns the layout. +pub const ErlNifEnv = opaque {}; + +/// ErlNifPid is a struct with a single ERL_NIF_TERM. Marked `extern` so +/// alignment matches the C definition. +pub const ErlNifPid = extern struct { + pid: ERL_NIF_TERM, +}; + +/// Opaque mutex handle. enif_mutex_create returns one; the others take a +/// pointer to it. +pub const ErlNifMutex = opaque {}; + +/// Char encoding for enif_get_atom / enif_get_string / enif_make_string. +pub const ErlNifCharEncoding = c_int; +pub const ERL_NIF_LATIN1: ErlNifCharEncoding = 1; +pub const ERL_NIF_UTF8: ErlNifCharEncoding = 2; + +/// Binary view. `data` points at heap-owned bytes; `size` is the length; +/// the trailing internal pointers (ref_bin, __spare__) are opaque to NIF +/// authors. Layout matches C exactly so `enif_inspect_binary(env, term, &bin)` +/// fills the same struct shape. +pub const ErlNifBinary = extern struct { + size: usize, + data: [*]u8, + ref_bin: ?*anyopaque = null, + __spare__: [2]?*anyopaque = .{ null, null }, +}; + +/// NIF table entry. `fptr` follows the standard NIF signature +/// `ERL_NIF_TERM (*)(ErlNifEnv*, int argc, const ERL_NIF_TERM argv[])`. +pub const ErlNifFunc = extern struct { + name: [*:0]const u8, + arity: c_uint, + fptr: ?*const fn (env: ?*ErlNifEnv, argc: c_int, argv: [*]const ERL_NIF_TERM) callconv(.c) ERL_NIF_TERM, + flags: c_uint, +}; + +// ── Term constructors ───────────────────────────────────────────────────── + +pub extern fn enif_make_atom(env: ?*ErlNifEnv, name: [*:0]const u8) ERL_NIF_TERM; +pub extern fn enif_make_int(env: ?*ErlNifEnv, i: c_int) ERL_NIF_TERM; +pub extern fn enif_make_double(env: ?*ErlNifEnv, d: f64) ERL_NIF_TERM; +pub extern fn enif_make_badarg(env: ?*ErlNifEnv) ERL_NIF_TERM; +pub extern fn enif_make_binary(env: ?*ErlNifEnv, bin: *ErlNifBinary) ERL_NIF_TERM; +pub extern fn enif_make_string(env: ?*ErlNifEnv, str: [*:0]const u8, enc: ErlNifCharEncoding) ERL_NIF_TERM; + +// ── Term inspectors ─────────────────────────────────────────────────────── + +/// Returns 1 on success, 0 on failure. Fills `bin` with the binary's +/// {size, data} view (no copy). +pub extern fn enif_inspect_binary(env: ?*ErlNifEnv, term: ERL_NIF_TERM, bin: *ErlNifBinary) c_int; + +/// Returns 1 on success, 0 on failure. Reads an Erlang charlist into a +/// fixed-size C string buffer (NUL-terminated on success). +pub extern fn enif_get_string( + env: ?*ErlNifEnv, + list: ERL_NIF_TERM, + buf: [*]u8, + len: c_uint, + enc: ErlNifCharEncoding, +) c_int; + +/// Returns 1 on success, 0 on failure. Reads an atom name into a buffer +/// (NUL-terminated on success). +pub extern fn enif_get_atom( + env: ?*ErlNifEnv, + atom: ERL_NIF_TERM, + buf: [*]u8, + len: c_uint, + enc: ErlNifCharEncoding, +) c_int; + +// ── Convenience wrappers ────────────────────────────────────────────────── +// Idiomatic Zig surface over the bare extern fns. Keeps NIF bodies tight. + +/// Make an atom from a comptime-known string literal. +pub inline fn atom(env: ?*ErlNifEnv, comptime name: [:0]const u8) ERL_NIF_TERM { + return enif_make_atom(env, name.ptr); +} + +/// The canonical `:ok` return. +pub inline fn ok(env: ?*ErlNifEnv) ERL_NIF_TERM { + return enif_make_atom(env, "ok"); +} + +/// The canonical `badarg` return — typed identically to `ok` so the call +/// sites read symmetrically. +pub inline fn badarg(env: ?*ErlNifEnv) ERL_NIF_TERM { + return enif_make_badarg(env); +} diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c index 1e2eb4dd..9958bf47 100644 --- a/android/jni/mob_nif.c +++ b/android/jni/mob_nif.c @@ -20,6 +20,15 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +// ── NIFs defined in mob_nif.zig (Phase 6b iter 3a) ──────────────────────────── +// The Zig file exports these with the standard NIF C-ABI signature; the +// static nif_funcs[] table below references them by symbol name. As later +// sub-iters port more NIFs, they get added to this extern block — eventually +// (iter 3d) the whole table moves to Zig and these externs go away. +extern ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); + // ── Cached JNI method IDs ──────────────────────────────────────────────────── static struct { @@ -676,11 +685,13 @@ void _mob_bridge_init_activity(JNIEnv *env, jobject activity) { LOGI("_mob_bridge_init_activity: MobBridge.init called"); } -// ── NIF: platform/0 ────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_atom(env, "android"); -} +// ── NIFs moved to mob_nif.zig (Phase 6b iter 3a) ───────────────────────────── +// `nif_platform/0`, `nif_log/1`, `nif_log/2` (+ the atom_to_android_priority +// helper that only `nif_log/2` used) are now defined in mob_nif.zig. The +// nif_funcs[] table below references them via the extern declarations near +// the top of this file. Behaviour is byte-for-byte equivalent — same atom +// names, same priority mapping, same 4 KB truncation, same fallback to +// `enif_get_string` for charlists. // ── NIF: color_scheme/0 ────────────────────────────────────────────────────── // Returns :light or :dark based on the Activity's current Configuration.uiMode. @@ -709,52 +720,6 @@ static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TER return atom; } -// ── NIF: log/1 ─────────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char buf[4096] = {0}; - ErlNifBinary bin; - if (enif_inspect_binary(env, argv[0], &bin)) { - size_t len = bin.size < sizeof(buf) - 1 ? bin.size : sizeof(buf) - 1; - memcpy(buf, bin.data, len); - buf[len] = 0; - } else if (!enif_get_string(env, argv[0], buf, sizeof(buf), ERL_NIF_LATIN1)) { - return enif_make_badarg(env); - } - __android_log_print(ANDROID_LOG_INFO, "Elixir", "%s", buf); - return enif_make_atom(env, "ok"); -} - -// ── NIF: log/2 ─────────────────────────────────────────────────────────────── - -static int atom_to_android_priority(ErlNifEnv *env, ERL_NIF_TERM level_atom) { - char level[16]; - if (!enif_get_atom(env, level_atom, level, sizeof(level), ERL_NIF_LATIN1)) - return ANDROID_LOG_INFO; - if (strcmp(level, "debug") == 0) - return ANDROID_LOG_DEBUG; - if (strcmp(level, "warning") == 0) - return ANDROID_LOG_WARN; - if (strcmp(level, "error") == 0) - return ANDROID_LOG_ERROR; - return ANDROID_LOG_INFO; -} - -static ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char buf[4096] = {0}; - int priority = atom_to_android_priority(env, argv[0]); - ErlNifBinary bin; - if (enif_inspect_binary(env, argv[1], &bin)) { - size_t len = bin.size < sizeof(buf) - 1 ? bin.size : sizeof(buf) - 1; - memcpy(buf, bin.data, len); - buf[len] = 0; - } else if (!enif_get_string(env, argv[1], buf, sizeof(buf), ERL_NIF_LATIN1)) { - return enif_make_badarg(env); - } - __android_log_print(priority, "Elixir", "%s", buf); - return enif_make_atom(env, "ok"); -} - // ── NIF: set_root/1 ────────────────────────────────────────────────────────── // Accepts a JSON binary and passes it to MobBridge.setRootJson(String) on the // Kotlin side. Compose state update is thread-safe — no main-thread hop needed. diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig new file mode 100644 index 00000000..cf026a01 --- /dev/null +++ b/android/jni/mob_nif.zig @@ -0,0 +1,112 @@ +//! mob_nif.zig — Mob Android NIF implementations (Zig). +//! +//! Phase 6b iter 3 of the build-system migration: incremental port of +//! mob_nif.c (~2570 lines, 79 NIFs) to Zig. The C file stays in the build +//! alongside this one — both contribute symbols to the final lib.so. +//! mob_nif.c's static `ErlNifFunc nif_funcs[]` table references the Zig +//! exports here via `extern` declarations at the top of mob_nif.c. +//! +//! Sub-iter sequence: +//! * iter 3a (this file as it lands): 3 standalone NIFs — platform/0, +//! log/1, log/2. No JNI, no shared state. Proves the cross-language +//! linkage pattern. +//! * iter 3b: test harness NIFs (ui_tree, tap_xy, type_text, swipe, etc.). +//! * iter 3c: event senders + cached MobBridge method-ID struct + handle +//! registries + per-handle throttle state. +//! * iter 3d: remaining feature NIFs (storage, WebView, alert, +//! action_sheet, toast, native view components, lifecycle, +//! Mob.Device). Moves the NIF table itself here. mob_nif.c deleted. +//! +//! All exports use the C ABI so the C-side NIF table can reference them. + +const std = @import("std"); +const jni = @import("mob_zig.zig"); +const erts = @import("mob_erts.zig"); + +// ── Logging tag for NIFs that log to Android logcat ────────────────────── + +const ELIXIR_TAG: [*:0]const u8 = "Elixir"; + +// ── NIF: platform/0 ────────────────────────────────────────────────────── +// Returns the atom :android. iOS has a parallel `nif_platform` in +// `ios/mob_nif.m` that returns :ios. + +export fn nif_platform( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.atom(env, "android"); +} + +// ── NIF: log/1 ─────────────────────────────────────────────────────────── +// Accept either a binary or an Erlang charlist; emit under tag "Elixir" +// at ANDROID_LOG_INFO. Truncates at 4 KB (matches the C version's local +// buffer size). + +export fn nif_log( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [4096]u8 = @splat(0); + if (!fillBufferFromTerm(env, argv[0], &buf)) { + return erts.badarg(env); + } + const cstr: [*:0]const u8 = @ptrCast(&buf); + _ = jni.__android_log_print(jni.ANDROID_LOG_INFO, ELIXIR_TAG, "%s", cstr); + return erts.ok(env); +} + +// ── NIF: log/2 ─────────────────────────────────────────────────────────── +// argv[0] is a level atom (:debug | :info | :warning | :error); argv[1] is +// the message (binary or charlist). Unknown atom → INFO. + +export fn nif_log2( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [4096]u8 = @splat(0); + const priority = atomToAndroidPriority(env, argv[0]); + if (!fillBufferFromTerm(env, argv[1], &buf)) { + return erts.badarg(env); + } + const cstr: [*:0]const u8 = @ptrCast(&buf); + _ = jni.__android_log_print(priority, ELIXIR_TAG, "%s", cstr); + return erts.ok(env); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Pull a binary or charlist into a NUL-terminated buffer. Returns false if +/// neither inspect_binary nor get_string succeeded. +fn fillBufferFromTerm(env: ?*erts.ErlNifEnv, term: erts.ERL_NIF_TERM, buf: *[4096]u8) bool { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, term, &bin) != 0) { + const len = @min(bin.size, buf.len - 1); + @memcpy(buf[0..len], bin.data[0..len]); + buf[len] = 0; + return true; + } + return erts.enif_get_string(env, term, buf.ptr, @intCast(buf.len), erts.ERL_NIF_LATIN1) != 0; +} + +/// Map :debug / :info / :warning / :error to the Android log priority. +/// Unknown atom → INFO (matches the C default). +fn atomToAndroidPriority(env: ?*erts.ErlNifEnv, level_atom: erts.ERL_NIF_TERM) c_int { + var level: [16]u8 = @splat(0); + if (erts.enif_get_atom(env, level_atom, &level, level.len, erts.ERL_NIF_LATIN1) == 0) { + return jni.ANDROID_LOG_INFO; + } + const len = jni.zLen(&level); + const view = level[0..len]; + if (std.mem.eql(u8, view, "debug")) return jni.ANDROID_LOG_DEBUG; + if (std.mem.eql(u8, view, "warning")) return jni.ANDROID_LOG_WARN; + if (std.mem.eql(u8, view, "error")) return jni.ANDROID_LOG_ERROR; + return jni.ANDROID_LOG_INFO; +} From cfdeb8789450e876a4f28126ba217d7a5c175ed5 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 13:29:41 -0600 Subject: [PATCH 024/254] build_system_migration: Phase 6b iter 3a logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the iter 3 slicing decision (sized into 3a–3d because of mob_nif.c's internal coupling between cached method IDs, registries, mutexes, senders, and the dispatch table) and what iter 3a shipped: the cross-language linkage pattern (C + Zig .o files coexisting in the same .so, table in C resolves Zig exports via extern), the new mob_erts.zig FFI surface, and the three standalone NIFs that validate the pattern. Documents what iter 3b/3c/3d will cover so the multi-iter handoff is unambiguous. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index f7814139..cc0db270 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1138,3 +1138,57 @@ something useful even if the total project pauses. binding module from iter 2 covers the JNI surface today; additional CallStaticXxxMethod / array-op vtable slots get added as each iter needs them. + + - iter 3a (foundation + 3 standalone NIFs): the inaugural slice. + Establishes the cross-language linkage pattern that the + remaining sub-iters will reuse: + + • NEW `android/jni/mob_erts.zig` — hand-declared ERL_NIF + FFI surface (ERL_NIF_TERM, ErlNifEnv, ErlNifPid, + ErlNifMutex, ErlNifBinary, ErlNifFunc, ErlNifCharEncoding, + plus the enif_make_* / enif_get_* / enif_inspect_* set + that iter 3a's NIFs need). Companion to mob_zig.zig — + same rationale (Zig 0.17 @cImport is gone, translate-c + unreliable on deeply nested OTP headers, surface small + + stable enough to hand-declare). + • NEW `android/jni/mob_nif.zig` — exports `nif_platform/0`, + `nif_log/1`, `nif_log/2`. Byte-for-byte equivalent to + the C versions removed from mob_nif.c. + • mob_nif.c shrinks ~35 lines (3 NIF defs + 1 helper); the + static `ErlNifFunc nif_funcs[]` table now resolves those + functions at link time via an `extern ERL_NIF_TERM ...` + block near the top. As iter 3b/3c/3d port more NIFs, the + extern block grows and the .c file shrinks. iter 3d + moves the table itself to Zig and removes mob_nif.c. + + Two .o files coexist in the link — `/mob_nif.o` (the + shrinking C side) and `/mob_nif_zig.o`. Both contribute + symbols to lib.so. The mob_new build template adds the + .zig source as a separate spec entry; the loop already + handles per-source .zig vs .c detection from iter 1. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o; symbol + check confirms `nif_platform`, `nif_log`, `nif_log2` + exported and only the expected ERL_NIF / Android-log + undefined references. mob_nif.c passes clang-format. + 224/224 mob_new tests + full mob test suite pass. Full + Android end-to-end smoke deploy deferred to bundle with the + next sub-iter so we test once over a meaningful slice. + + - iter 3b (planned): port the test harness NIFs — `ui_tree/0`, + `ui_view_tree/0`, `screen_info/0`, `tap/1`, `tap_xy/2`, + `type_text/1`, `delete_backward/0`, `key_press/1`, + `clear_text/0`, `long_press_xy/3`, `swipe_xy/4`. These are + largely independent of the cached MobBridge struct (they + look up their methods on demand or cache lazily) so they + slice cleanly. + - iter 3c (planned): the concurrency-heavy core — cached + MobBridge method ID struct, tap/component handle registries, + per-handle throttle state, all `mob_send_*` event senders. + Must coordinate with beam_jni.c which is the C-side caller + of the public sender API. + - iter 3d (planned): remaining feature NIFs — storage, WebView, + alert/action_sheet/toast, native view components, background + lifecycle, Mob.Device. Moves the `ErlNifFunc nif_funcs[]` + table to Zig. mob_nif.c deleted. From 240d483bff717563ef3967df44ce441428d3ee8d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 13:51:17 -0600 Subject: [PATCH 025/254] Phase 6b iter 3b (mob side): port test harness NIFs + cached Bridge to Zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the mob_nif port. The big move is the cached MobBridge method-ID struct + get_jenv — both shared by senders (iter 3c) and the remaining feature NIFs (iter 3d), so moving them here unblocks the later sub-iters with a single coordination event instead of three. Moved to mob_nif.zig: * BridgeMethods extern struct (52 fields) + the `Bridge` global var. C side keeps a matching `struct BridgeMethods` declaration + an `extern struct BridgeMethods Bridge;` — field order is load-bearing and changes must land in both files simultaneously. * get_jenv (thread-attach helper) — exported with C ABI, the C-side senders + feature NIFs call it just like before. * jstring_to_bin + cstr_to_bin helpers (Zig-private; only the test harness NIFs needed them and they're all in Zig now). * 13 test harness NIFs: ui_tree, ui_view_tree, screen_info, ui_debug, ax_action{,_at_xy} (Android stubs), tap, tap_xy, type_text, delete_backward, key_press (Android stub), clear_text, long_press_xy, swipe_xy. Coordinates in dp, matching iOS. Extensions to the FFI binding modules: * mob_zig.zig — typed previously-opaque vtable slots that the test harness needs: GetStaticMethodID, CallStaticObjectMethod (variadic), CallStaticBooleanMethod (variadic), CallStaticVoidMethod (variadic), NewStringUTF, DeleteLocalRef, ExceptionClear, GetArrayLength, GetFloatArrayRegion. Added padding slots for the intervening array entries (NewObjectArray through other Get*ArrayRegion variants) so the layout up to GetFloatArrayRegion matches AOSP's jni.h slot-for-slot. Added wrapper helpers (getStaticMethodID, newStringUTF, deleteLocalRef, exceptionClear, getArrayLength, getFloatArrayRegion) + extern malloc/free (used for the unbounded-binary path in nif_tap / nif_type_text). * mob_erts.zig — added enif_make_list_cell, enif_make_list_from_array, enif_make_tuple_from_array, enif_make_map_from_arrays, enif_alloc_binary, enif_inspect_iolist_as_binary, enif_get_int, enif_get_double. Convenience wrappers: makeTuple (variadic-arity via enif_make_tuple_from_array), makeList, makeMap, errorTuple, getNumber (accept-double-or-int helper used by the coordinate-taking NIFs). mob_nif.c shrinks ~409 lines (the static Bridge struct + get_jenv + jstring_to_bin + cstr_to_bin + 13 NIF definitions, replaced by an expanded extern block at the top, a named-struct declaration, and a short pointer-to-Zig comment block). Verified: standalone `zig build-obj -target aarch64-linux-android.24` produces a clean mob_nif.o exporting Bridge + get_jenv + 17 nif_* symbols (3 from iter 3a + 13 from iter 3b + the ax_action pair). Undefined references match what mob_nif.c (g_jvm), libbeam (enif_*), liblog (__android_log_print), and bionic (malloc/free, memcpy/memset/ strlen) provide at the final link. mob_nif.c passes clang-format. 702/702 mob tests + 224/224 mob_new tests pass; credo strict clean on both. Full Android end-to-end smoke deploy deferred to bundle with iter 3c so the senders + handle registries ship in one test pass. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_erts.zig | 84 +++++- android/jni/mob_nif.c | 482 +++----------------------------- android/jni/mob_nif.zig | 581 ++++++++++++++++++++++++++++++++++++++- android/jni/mob_zig.zig | 111 +++++++- 4 files changed, 795 insertions(+), 463 deletions(-) diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index 515541aa..f4128431 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -10,7 +10,10 @@ //! //! Phase 6b iter 3a introduces this file. It declares only what iter 3a's //! NIFs (nif_platform, nif_log, nif_log2) need; later iters extend it as -//! their ported NIFs require more of the ERL_NIF surface. +//! their ported NIFs require more of the ERL_NIF surface. iter 3b adds the +//! list / tuple / map constructors, enif_get_int / enif_get_double, +//! enif_alloc_binary, and enif_inspect_iolist_as_binary for the test +//! harness NIFs. //! //! Authoritative reference: OTP 27+ `erl_nif.h` and `erl_nif_api_funcs.h`. @@ -70,12 +73,46 @@ pub extern fn enif_make_badarg(env: ?*ErlNifEnv) ERL_NIF_TERM; pub extern fn enif_make_binary(env: ?*ErlNifEnv, bin: *ErlNifBinary) ERL_NIF_TERM; pub extern fn enif_make_string(env: ?*ErlNifEnv, str: [*:0]const u8, enc: ErlNifCharEncoding) ERL_NIF_TERM; +// List construction (iter 3b). +// +// `enif_make_list` in C is variadic with a count prefix; we expose the +// non-variadic `enif_make_list_from_array` and `enif_make_list_cell` +// (prepend) primitives. Fixed-arity helpers below are built on top. +pub extern fn enif_make_list_cell(env: ?*ErlNifEnv, car: ERL_NIF_TERM, cdr: ERL_NIF_TERM) ERL_NIF_TERM; +pub extern fn enif_make_list_from_array(env: ?*ErlNifEnv, arr: [*]const ERL_NIF_TERM, cnt: c_uint) ERL_NIF_TERM; + +// Tuple construction (iter 3b). `enif_make_tuple` is variadic; the +// non-variadic `enif_make_tuple_from_array` is the underlying primitive. +pub extern fn enif_make_tuple_from_array(env: ?*ErlNifEnv, arr: [*]const ERL_NIF_TERM, cnt: c_uint) ERL_NIF_TERM; + +// Map construction (iter 3b). Returns 1 on success, 0 on duplicate key. +// `keys` and `values` are parallel arrays of length `cnt`; `*map_out` is +// populated on success. +pub extern fn enif_make_map_from_arrays( + env: ?*ErlNifEnv, + keys: [*]const ERL_NIF_TERM, + values: [*]const ERL_NIF_TERM, + cnt: usize, + map_out: *ERL_NIF_TERM, +) c_int; + +// Binary allocation (iter 3b). Returns 1 on success, 0 on OOM. The caller +// owns `bin.data` until it's wrapped via `enif_make_binary`, after which +// BEAM owns it. +pub extern fn enif_alloc_binary(size: usize, bin: *ErlNifBinary) c_int; + // ── Term inspectors ─────────────────────────────────────────────────────── /// Returns 1 on success, 0 on failure. Fills `bin` with the binary's /// {size, data} view (no copy). pub extern fn enif_inspect_binary(env: ?*ErlNifEnv, term: ERL_NIF_TERM, bin: *ErlNifBinary) c_int; +/// Returns 1 on success, 0 on failure. Like enif_inspect_binary, but +/// accepts an iolist (list of binaries/integers) and materialises a +/// contiguous binary view. Used when callers can pass either a plain +/// binary or an iolist (e.g. set_root/1). +pub extern fn enif_inspect_iolist_as_binary(env: ?*ErlNifEnv, term: ERL_NIF_TERM, bin: *ErlNifBinary) c_int; + /// Returns 1 on success, 0 on failure. Reads an Erlang charlist into a /// fixed-size C string buffer (NUL-terminated on success). pub extern fn enif_get_string( @@ -96,6 +133,12 @@ pub extern fn enif_get_atom( enc: ErlNifCharEncoding, ) c_int; +/// Read an integer term. Returns 1 on success, 0 on failure. +pub extern fn enif_get_int(env: ?*ErlNifEnv, term: ERL_NIF_TERM, ip: *c_int) c_int; + +/// Read a double term. Returns 1 on success, 0 on failure. +pub extern fn enif_get_double(env: ?*ErlNifEnv, term: ERL_NIF_TERM, dp: *f64) c_int; + // ── Convenience wrappers ────────────────────────────────────────────────── // Idiomatic Zig surface over the bare extern fns. Keeps NIF bodies tight. @@ -114,3 +157,42 @@ pub inline fn ok(env: ?*ErlNifEnv) ERL_NIF_TERM { pub inline fn badarg(env: ?*ErlNifEnv) ERL_NIF_TERM { return enif_make_badarg(env); } + +/// Build an N-tuple from a comptime-known list of terms. Mirrors the C +/// `enif_make_tupleN` inlines but works for any arity via the underlying +/// `enif_make_tuple_from_array` primitive. +pub inline fn makeTuple(env: ?*ErlNifEnv, elems: anytype) ERL_NIF_TERM { + const arr: [elems.len]ERL_NIF_TERM = elems; + return enif_make_tuple_from_array(env, &arr, elems.len); +} + +/// `{:error, Reason}` 2-tuple convenience. +pub inline fn errorTuple(env: ?*ErlNifEnv, reason: ERL_NIF_TERM) ERL_NIF_TERM { + return makeTuple(env, .{ enif_make_atom(env, "error"), reason }); +} + +/// Build a proper Erlang list from a slice of terms. +pub inline fn makeList(env: ?*ErlNifEnv, items: []const ERL_NIF_TERM) ERL_NIF_TERM { + return enif_make_list_from_array(env, items.ptr, @intCast(items.len)); +} + +/// Build a map from parallel key/value slices. Returns null on duplicate +/// key (matches the C convention of `enif_make_map_from_arrays` returning 0). +pub inline fn makeMap(env: ?*ErlNifEnv, keys: []const ERL_NIF_TERM, values: []const ERL_NIF_TERM) ?ERL_NIF_TERM { + std.debug.assert(keys.len == values.len); + var out: ERL_NIF_TERM = undefined; + if (enif_make_map_from_arrays(env, keys.ptr, values.ptr, keys.len, &out) == 0) return null; + return out; +} + +/// Read a numeric term as a double, accepting either a double or an integer +/// term. Returns null if neither path succeeds. Mirrors a common pattern +/// in the test harness NIFs where Erlang callers may pass `100` or `100.0` +/// interchangeably for coordinates. +pub inline fn getNumber(env: ?*ErlNifEnv, term: ERL_NIF_TERM) ?f64 { + var d: f64 = 0; + if (enif_get_double(env, term, &d) != 0) return d; + var i: c_int = 0; + if (enif_get_int(env, term, &i) != 0) return @floatFromInt(i); + return null; +} diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c index 9958bf47..7a19ee09 100644 --- a/android/jni/mob_nif.c +++ b/android/jni/mob_nif.c @@ -20,18 +20,38 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -// ── NIFs defined in mob_nif.zig (Phase 6b iter 3a) ──────────────────────────── +// ── NIFs defined in mob_nif.zig ─────────────────────────────────────────────── // The Zig file exports these with the standard NIF C-ABI signature; the // static nif_funcs[] table below references them by symbol name. As later // sub-iters port more NIFs, they get added to this extern block — eventually // (iter 3d) the whole table moves to Zig and these externs go away. +// iter 3a: extern ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); extern ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); extern ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); - -// ── Cached JNI method IDs ──────────────────────────────────────────────────── - -static struct { +// iter 3b — test harness: +extern ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); + +// ── Cached JNI method IDs (definition moved to mob_nif.zig in iter 3b) ────── +// The matching extern struct here is the C view of the same memory the Zig +// side defines and exports. Field order is load-bearing — drift here will +// silently mis-resolve method IDs at runtime. When a Bridge field is added +// or removed, BOTH this declaration AND the BridgeMethods extern struct in +// mob_nif.zig must change together. +struct BridgeMethods { jclass cls; jmethodID set_root; jmethodID move_to_back; @@ -91,7 +111,13 @@ static struct { jmethodID clear_text; jmethodID long_press_xy; jmethodID swipe_xy; -} Bridge; +}; +extern struct BridgeMethods Bridge; + +// JNI thread-attach helper (definition moved to mob_nif.zig). The senders +// + feature NIFs still in this file call it like before; the function +// itself is now exported with C ABI from Zig. +extern JNIEnv *get_jenv(int *attached); // ── Tap handle registry ─────────────────────────────────────────────────────── // Cleared before every render. Max 256 tappable elements per frame. @@ -609,17 +635,7 @@ void mob_handle_back(void) { enif_free_env(env); } -// ── JNI helpers ────────────────────────────────────────────────────────────── - -static JNIEnv *get_jenv(int *attached) { - JNIEnv *env = NULL; - *attached = 0; - if ((*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) { - (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL); - *attached = 1; - } - return env; -} +// ── JNI helpers (get_jenv moved to mob_nif.zig in iter 3b) ────────────────── // ── Cache MobBridge class (called from mob_beam.c) ─────────────────────────── @@ -1478,431 +1494,13 @@ static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv *env, int argc, const ERL return call_bridge_pid_str(env, Bridge.notify_register_push, pid, NULL); } -// ── NIF table & load ───────────────────────────────────────────────────────── - -// ── Test harness NIFs ───────────────────────────────────────────────────────── -// -// Android implementation notes vs iOS: -// - View tree walk uses android.view.View hierarchy (Compose exposes Views) -// - Touch injection via DecorView.dispatchTouchEvent — no INJECT_EVENTS needed -// - Text input via InputConnection.commitText — works for Compose TextField -// - All blocking operations use CountDownLatch on the Kotlin side; -// from C we just call the JNI method which blocks until the latch fires -// - Coordinates in dp (density-independent pixels), matching iOS convention - -// Helper: jstring → ERL_NIF_TERM binary (UTF-8). Deletes local ref. -static ERL_NIF_TERM jstring_to_bin(ErlNifEnv *env, JNIEnv *jenv, jstring js) { - if (!js) - return enif_make_atom(env, "nil"); - const char *utf = (*jenv)->GetStringUTFChars(jenv, js, NULL); - if (!utf) - return enif_make_atom(env, "nil"); - size_t len = strlen(utf); - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf, len); - (*jenv)->ReleaseStringUTFChars(jenv, js, utf); - (*jenv)->DeleteLocalRef(jenv, js); - return enif_make_binary(env, &bin); -} - -// Helper: make a binary term from a C string (does NOT delete jstring). -static ERL_NIF_TERM cstr_to_bin(ErlNifEnv *env, const char *s, size_t len) { - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, s, len); - return enif_make_binary(env, &bin); -} - -// nif_ui_tree/0 — returns [{type_atom, label_binary, value_binary, {x,y,w,h}}, ...] -// -// Calls MobBridge.uiTree() which returns a newline-separated string: -// type|label|value|x|y|w|h\n... -// Parses that into a list of 4-tuples matching the iOS ui_tree format. -static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); - if (!jresult) { - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_list(env, 0); - } - - const char *raw = (*jenv)->GetStringUTFChars(jenv, jresult, NULL); - ERL_NIF_TERM list = enif_make_list(env, 0); - - // Parse lines in reverse (we'll reverse the list at the end) - // Format per line: type|label|value|x|y|w|h - const char *p = raw; - // Collect all lines into a temp array first (we build list in reverse for efficiency) - // Simple approach: walk forward, build list, reverse at end - ERL_NIF_TERM items[512]; - int count = 0; - - while (*p && count < 512) { - // Find end of line - const char *nl = strchr(p, '\n'); - if (!nl) - break; - size_t line_len = nl - p; - char line[512]; - if (line_len >= sizeof(line)) { - p = nl + 1; - continue; - } - memcpy(line, p, line_len); - line[line_len] = 0; - p = nl + 1; - - // Split on '|': type, label, value, x, y, w, h - char *fields[7]; - int nf = 0; - char *tok = line; - for (int i = 0; i < 7; i++) { - fields[i] = tok; - char *sep = (i < 6) ? strchr(tok, '|') : NULL; - if (sep) { - *sep = 0; - tok = sep + 1; - nf++; - } else { - nf = i + 1; - break; - } - } - if (nf < 7) - continue; - - double x = atof(fields[3]); - double y = atof(fields[4]); - double w = atof(fields[5]); - double h = atof(fields[6]); - - ERL_NIF_TERM frame = - enif_make_tuple4(env, enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, w), enif_make_double(env, h)); - - // label and value: non-empty → binary, empty → atom nil - size_t llen = strlen(fields[1]); - size_t vlen = strlen(fields[2]); - ERL_NIF_TERM label = - llen > 0 ? cstr_to_bin(env, fields[1], llen) : enif_make_atom(env, "nil"); - ERL_NIF_TERM value = - vlen > 0 ? cstr_to_bin(env, fields[2], vlen) : enif_make_atom(env, "nil"); - - items[count++] = enif_make_tuple4(env, enif_make_atom(env, fields[0]), label, value, frame); - } - - (*jenv)->ReleaseStringUTFChars(jenv, jresult, raw); - (*jenv)->DeleteLocalRef(jenv, jresult); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - - // Build list from items array (forward order) - list = enif_make_list(env, 0); - for (int i = count - 1; i >= 0; i--) - list = enif_make_list_cell(env, items[i], list); - return list; -} - -// nif_ui_view_tree/0 — returns nested-map UI tree from MobBridge.uiViewTree(). -// -// Bridge contract: Kotlin side returns a JSON string of the form: -// {"type":"root","label":null,"value":null,"frame":[0,0,W,H],"children":[ ... ]} -// Each child has the same shape. Empty registry returns an empty children list. -// -// The JSON is parsed by Mob.Test.tree/1 on the Erlang side (jason decode is fast -// and avoids hand-rolling a JSON tokenizer in C). Returns {:error, :not_loaded} -// if MobBridge.uiViewTree() isn't present (early adopter apps without registry). -static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_view_tree) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jresult = - (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_view_tree); - ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return result; -} - -// nif_screen_info/0 — returns %{width, height, scale, safe_area: %{...}} -// -// Width/height are in dp (already px-divided by density on the Kotlin side). -// scale is the density factor (1.0/1.5/2.0/2.625/3.0/...) — same role as -// UIScreen.scale on iOS. -// -// Bridge contract: MobBridge.screenInfo() returns float[6] = [w, h, scale, -// safe_top, safe_bottom, safe_left]; safe_right is computed as 0 here for -// brevity but the Kotlin side should send it once added to the array. -// -// Falls back to safe_area-only info if screenInfo() isn't bound (older bridges). -static ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - float vals[7] = {0}; // w, h, scale, top, bottom, left, right - if (Bridge.screen_info) { - jfloatArray arr = - (jfloatArray)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.screen_info); - if (arr) { - jsize len = (*jenv)->GetArrayLength(jenv, arr); - if (len > 7) - len = 7; - (*jenv)->GetFloatArrayRegion(jenv, arr, 0, len, vals); - (*jenv)->DeleteLocalRef(jenv, arr); - } - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - - ERL_NIF_TERM sa_keys[4] = {enif_make_atom(env, "top"), enif_make_atom(env, "bottom"), - enif_make_atom(env, "left"), enif_make_atom(env, "right")}; - ERL_NIF_TERM sa_vals[4] = { - enif_make_double(env, (double)vals[3]), enif_make_double(env, (double)vals[4]), - enif_make_double(env, (double)vals[5]), enif_make_double(env, (double)vals[6])}; - ERL_NIF_TERM safe_area; - enif_make_map_from_arrays(env, sa_keys, sa_vals, 4, &safe_area); - - ERL_NIF_TERM keys[4] = {enif_make_atom(env, "width"), enif_make_atom(env, "height"), - enif_make_atom(env, "scale"), enif_make_atom(env, "safe_area")}; - ERL_NIF_TERM vvals[4] = {enif_make_double(env, (double)vals[0]), - enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), safe_area}; - ERL_NIF_TERM result; - enif_make_map_from_arrays(env, keys, vvals, 4, &result); - return result; -} - -// nif_ax_action/2 and nif_ax_action_at_xy/3 — Android stubs. -// -// Both are iOS-only today. Compose semantics walker (the proper Android -// implementation) is queued under WireTap (see future_developments.md). -// Return a clear error so callers get `{:error, :not_supported_on_android}` -// instead of an `:undef` crash. -static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); -} -static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); -} - -// nif_ui_debug/0 — returns raw uiTree string as a binary (for debugging) -static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); - ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return result; -} - -// nif_tap/1 — tap by accessibility label binary -static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_by_label) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *label = (char *)malloc(bin.size + 1); - if (!label) - return enif_make_atom(env, "error"); - memcpy(label, bin.data, bin.size); - label[bin.size] = 0; - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jlabel = (*jenv)->NewStringUTF(jenv, label); - free(label); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_by_label, jlabel); - (*jenv)->DeleteLocalRef(jenv, jlabel); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_element_with_label")); -} - -// nif_tap_xy/2 — tap at (x, y) dp coordinates -static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_xy) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - double x, y; - if (!enif_get_double(env, argv[0], &x)) { - int ix; - if (!enif_get_int(env, argv[0], &ix)) - return enif_make_badarg(env); - x = ix; - } - if (!enif_get_double(env, argv[1], &y)) { - int iy; - if (!enif_get_int(env, argv[1], &iy)) - return enif_make_badarg(env); - y = iy; - } - - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean ok = - (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_xy, (jfloat)x, (jfloat)y); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} - -// nif_type_text/1 — type text into the focused view -static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.type_text) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *text = (char *)malloc(bin.size + 1); - if (!text) - return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.type_text, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_delete_backward/0 — delete one character backward -static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.delete_backward) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.delete_backward); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_key_press/1 — not yet implemented on Android (no KeyCharacterMap lookup) -static ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_implemented")); -} - -// nif_clear_text/0 — select-all + delete in the focused view -static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.clear_text) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.clear_text); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_long_press_xy/3 — long press at (x, y) for duration_ms milliseconds -static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.long_press_xy) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - double x, y; - int dur; - if (!enif_get_double(env, argv[0], &x)) { - int ix; - if (!enif_get_int(env, argv[0], &ix)) - return enif_make_badarg(env); - x = ix; - } - if (!enif_get_double(env, argv[1], &y)) { - int iy; - if (!enif_get_int(env, argv[1], &iy)) - return enif_make_badarg(env); - y = iy; - } - if (!enif_get_int(env, argv[2], &dur)) - return enif_make_badarg(env); - - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.long_press_xy, - (jfloat)x, (jfloat)y, (jlong)dur); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} - -// nif_swipe_xy/4 — swipe from (x1,y1) to (x2,y2) in dp -static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.swipe_xy) - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_loaded")); - double x1, y1, x2, y2; - if (!enif_get_double(env, argv[0], &x1)) { - int i; - if (!enif_get_int(env, argv[0], &i)) - return enif_make_badarg(env); - x1 = i; - } - if (!enif_get_double(env, argv[1], &y1)) { - int i; - if (!enif_get_int(env, argv[1], &i)) - return enif_make_badarg(env); - y1 = i; - } - if (!enif_get_double(env, argv[2], &x2)) { - int i; - if (!enif_get_int(env, argv[2], &i)) - return enif_make_badarg(env); - x2 = i; - } - if (!enif_get_double(env, argv[3], &y2)) { - int i; - if (!enif_get_int(env, argv[3], &i)) - return enif_make_badarg(env); - y2 = i; - } - - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.swipe_xy, (jfloat)x1, - (jfloat)y1, (jfloat)x2, (jfloat)y2); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} +// ── Test harness NIFs moved to mob_nif.zig (Phase 6b iter 3b) ──────────────── +// nif_ui_tree, nif_ui_view_tree, nif_screen_info, nif_ui_debug, +// nif_ax_action{,_at_xy}, nif_tap, nif_tap_xy, nif_type_text, +// nif_delete_backward, nif_key_press, nif_clear_text, nif_long_press_xy, +// nif_swipe_xy + the jstring_to_bin / cstr_to_bin helpers used only by +// them now live in mob_nif.zig. The nif_funcs[] table below resolves them +// via the extern declarations near the top of this file. // ── Storage ─────────────────────────────────────────────────────────────────── diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index cf026a01..c474f03c 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -7,12 +7,17 @@ //! exports here via `extern` declarations at the top of mob_nif.c. //! //! Sub-iter sequence: -//! * iter 3a (this file as it lands): 3 standalone NIFs — platform/0, -//! log/1, log/2. No JNI, no shared state. Proves the cross-language -//! linkage pattern. -//! * iter 3b: test harness NIFs (ui_tree, tap_xy, type_text, swipe, etc.). -//! * iter 3c: event senders + cached MobBridge method-ID struct + handle -//! registries + per-handle throttle state. +//! * iter 3a: 3 standalone NIFs — platform/0, log/1, log/2. No JNI, no +//! shared state. Proved the cross-language linkage pattern. +//! * iter 3b (this iter): test harness NIFs (ui_tree, ui_view_tree, +//! screen_info, tap, tap_xy, type_text, delete_backward, key_press, +//! clear_text, long_press_xy, swipe_xy, ax_action stubs, ui_debug) +//! + the cached `Bridge` MobBridge method-ID struct + `get_jenv` (the +//! thread-attach helper). Moving Bridge/get_jenv here unblocks the +//! remaining sub-iters — both senders (iter 3c) and the feature NIFs +//! (iter 3d) reach into the same struct. +//! * iter 3c: event senders + tap/component handle registries + +//! per-handle throttle state. //! * iter 3d: remaining feature NIFs (storage, WebView, alert, //! action_sheet, toast, native view components, lifecycle, //! Mob.Device). Moves the NIF table itself here. mob_nif.c deleted. @@ -110,3 +115,567 @@ fn atomToAndroidPriority(env: ?*erts.ErlNifEnv, level_atom: erts.ERL_NIF_TERM) c if (std.mem.eql(u8, view, "error")) return jni.ANDROID_LOG_ERROR; return jni.ANDROID_LOG_INFO; } + +// ── Cached MobBridge method IDs (Phase 6b iter 3b) ─────────────────────── +// Moved from mob_nif.c's `static struct { ... } Bridge;`. The C side now +// extern-declares a matching `struct BridgeMethods Bridge` so the senders +// and feature NIFs that haven't been ported yet can still read these +// fields. Field order matches the C struct exactly — drift here will +// silently mis-resolve method IDs at runtime. +// +// The set_startup_phase / set_startup_error pair is populated by mob_beam +// (during BEAM startup, before NIFs load); the rest are filled by +// nif_load on the BEAM-side load callback. + +pub const BridgeMethods = extern struct { + cls: jni.JClass = null, + set_root: jni.JMethodID = null, + move_to_back: jni.JMethodID = null, + get_safe_area: jni.JMethodID = null, + get_color_scheme: jni.JMethodID = null, + haptic: jni.JMethodID = null, + clipboard_put: jni.JMethodID = null, + clipboard_get: jni.JMethodID = null, + share_text: jni.JMethodID = null, + open_url: jni.JMethodID = null, + request_permission: jni.JMethodID = null, + biometric_authenticate: jni.JMethodID = null, + location_get_once: jni.JMethodID = null, + location_start: jni.JMethodID = null, + location_stop: jni.JMethodID = null, + camera_capture_photo: jni.JMethodID = null, + camera_capture_video: jni.JMethodID = null, + camera_start_preview: jni.JMethodID = null, + camera_stop_preview: jni.JMethodID = null, + alert_show: jni.JMethodID = null, + action_sheet_show: jni.JMethodID = null, + toast_show: jni.JMethodID = null, + webview_eval_js: jni.JMethodID = null, + webview_post_message: jni.JMethodID = null, + webview_can_go_back: jni.JMethodID = null, + webview_go_back: jni.JMethodID = null, + photos_pick: jni.JMethodID = null, + files_pick: jni.JMethodID = null, + audio_start_recording: jni.JMethodID = null, + audio_stop_recording: jni.JMethodID = null, + audio_play: jni.JMethodID = null, + audio_stop_playback: jni.JMethodID = null, + audio_set_volume: jni.JMethodID = null, + motion_start: jni.JMethodID = null, + motion_stop: jni.JMethodID = null, + scanner_scan: jni.JMethodID = null, + notify_schedule: jni.JMethodID = null, + notify_cancel: jni.JMethodID = null, + notify_register_push: jni.JMethodID = null, + take_launch_notification: jni.JMethodID = null, + storage_dir: jni.JMethodID = null, + storage_save_to_media_store: jni.JMethodID = null, + storage_external_files_dir: jni.JMethodID = null, + background_keep_alive: jni.JMethodID = null, + background_stop: jni.JMethodID = null, + // Cached before nif_load (used during BEAM startup before NIFs are loaded) + set_startup_phase: jni.JMethodID = null, + set_startup_error: jni.JMethodID = null, + // ── Test harness ────────────────────────────────────────────────────── + ui_tree: jni.JMethodID = null, + ui_view_tree: jni.JMethodID = null, + screen_info: jni.JMethodID = null, + tap_xy: jni.JMethodID = null, + tap_by_label: jni.JMethodID = null, + type_text: jni.JMethodID = null, + delete_backward: jni.JMethodID = null, + clear_text: jni.JMethodID = null, + long_press_xy: jni.JMethodID = null, + swipe_xy: jni.JMethodID = null, +}; + +/// Exported with C ABI so mob_nif.c (and beam_jni.c for the senders in +/// iter 3c) can extern-declare it and read/write the same memory. +pub export var Bridge: BridgeMethods = .{}; + +// ── Externs from mob_beam.zig (Phase 6b iter 2) ────────────────────────── +extern var g_jvm: ?*jni.JavaVM; +extern var g_activity: jni.JObject; + +// ── get_jenv: attach the current thread if needed ──────────────────────── +// Returns the env pointer; *attached is set to 1 iff this call had to +// attach (caller must DetachCurrentThread when done). Match the C +// signature byte-for-byte — `int *attached` in C → `*c_int` in Zig. +// Exported so the C-side senders + feature NIFs can call it. +// +// JNI_EDETACHED = -2 (from jni.h). When GetEnv returns it the calling +// thread is not yet attached; AttachCurrentThread takes care of that. +// Any other GetEnv return (JNI_OK = 0, JNI_EVERSION = -3) means "leave +// it alone" — attached stays 0 so we won't detach a thread we didn't +// attach (and detaching a Java-spawned thread aborts ART). +const JNI_EDETACHED: jni.JInt = -2; + +pub export fn get_jenv(attached: *c_int) ?*jni.JNIEnv { + attached.* = 0; + const jvm = g_jvm orelse return null; + var ptr: ?*anyopaque = null; + const rc = jvm.*.GetEnv.?(jvm, &ptr, jni.JNI_VERSION_1_6); + if (rc == JNI_EDETACHED) { + var env: ?*jni.JNIEnv = null; + if (jvm.*.AttachCurrentThread.?(jvm, &env, null) == jni.JNI_OK) { + attached.* = 1; + return env; + } + return null; + } + return @ptrCast(@alignCast(ptr)); +} + +/// Detach when get_jenv set *attached = 1. Convenience wrapper used by +/// every test harness NIF below — keeps the call-site idiom compact and +/// the comment-block "if attached → detach" rule local to one place. +inline fn detachIfAttached(attached: c_int) void { + if (attached != 0) { + if (g_jvm) |jvm| jni.detachCurrentThread(jvm); + } +} + +// ── Binary / string helpers ────────────────────────────────────────────── + +/// Make an `ErlNifBinary` from a C-style {ptr, len} pair and wrap it as a +/// term. BEAM owns the allocated bytes after make_binary returns. +fn cstrToBin(env: ?*erts.ErlNifEnv, src: [*]const u8, len: usize) erts.ERL_NIF_TERM { + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], src[0..len]); + return erts.enif_make_binary(env, &bin); +} + +/// jstring → binary term. Returns `:nil` if the jstring is null or the +/// UTF-8 view can't be obtained. Always releases the local ref + UTF +/// chars; caller doesn't need to clean up. +fn jstringToBin(env: ?*erts.ErlNifEnv, jenv: *jni.JNIEnv, js: jni.JString) erts.ERL_NIF_TERM { + if (js == null) return erts.atom(env, "nil"); + const utf = jni.getStringUTFChars(jenv, js) orelse return erts.atom(env, "nil"); + const len = std.mem.span(utf).len; + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf[0..len]); + jni.releaseStringUTFChars(jenv, js, utf); + jni.deleteLocalRef(jenv, js); + return erts.enif_make_binary(env, &bin); +} + +/// Return `{:error, atom}` after detaching if needed. Centralised so the +/// test harness NIFs don't repeat the boilerplate. +inline fn errorAtom(env: ?*erts.ErlNifEnv, comptime reason: [:0]const u8) erts.ERL_NIF_TERM { + return erts.errorTuple(env, erts.atom(env, reason)); +} + +/// `{:error, :not_loaded}` — the early-bail path for NIFs that need a +/// Bridge method that wasn't compiled into the app (e.g. older mob_dev +/// versions that pre-date a Kotlin-side helper). +inline fn notLoaded(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + return errorAtom(env, "not_loaded"); +} + +// ── Test harness NIFs (Phase 6b iter 3b) ───────────────────────────────── +// Drive the running app from a Mac-side IEx via Erlang distribution. They +// look up cached method IDs on `Bridge`, hop into the JVM via get_jenv, +// dispatch via Compose's gesture/test bridge on the Kotlin side, then +// either return an `:ok` atom or a structured error tuple. dp coordinates, +// matching iOS convention. + +// nif_ui_tree/0 — returns [{type_atom, label_binary, value_binary, {x,y,w,h}}, ...] +// +// Calls MobBridge.uiTree() which returns a newline-separated string: +// type|label|value|x|y|w|h\n... +// Parses that into a list of 4-tuples matching the iOS ui_tree format. +export fn nif_ui_tree( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_tree == null) return notLoaded(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_tree); + if (jresult == null) { + detachIfAttached(attached); + return erts.makeList(env, &.{}); + } + + const raw = jni.getStringUTFChars(jenv, jresult); + var items_buf: [512]erts.ERL_NIF_TERM = undefined; + var count: usize = 0; + + if (raw) |r| { + const raw_slice = std.mem.span(r); + var line_it = std.mem.splitScalar(u8, raw_slice, '\n'); + while (line_it.next()) |line| { + if (count >= items_buf.len) break; + if (line.len == 0 or line.len >= 512) continue; + + // Split on '|': type | label | value | x | y | w | h + var fields: [7][]const u8 = undefined; + var field_count: usize = 0; + var field_it = std.mem.splitScalar(u8, line, '|'); + while (field_it.next()) |f| { + if (field_count >= 7) { + field_count += 1; // overflow marker + break; + } + fields[field_count] = f; + field_count += 1; + } + if (field_count != 7) continue; + + const x = std.fmt.parseFloat(f64, fields[3]) catch 0.0; + const y = std.fmt.parseFloat(f64, fields[4]) catch 0.0; + const w = std.fmt.parseFloat(f64, fields[5]) catch 0.0; + const h = std.fmt.parseFloat(f64, fields[6]) catch 0.0; + + const frame = erts.makeTuple(env, .{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, w), + erts.enif_make_double(env, h), + }); + + // Empty label/value → atom :nil, non-empty → binary. + const label = if (fields[1].len == 0) + erts.atom(env, "nil") + else + cstrToBin(env, fields[1].ptr, fields[1].len); + const value = if (fields[2].len == 0) + erts.atom(env, "nil") + else + cstrToBin(env, fields[2].ptr, fields[2].len); + + // The type field is small and unbounded in length theoretically; + // copy it into a NUL-terminated buffer so enif_make_atom is safe. + var type_buf: [64]u8 = @splat(0); + const tlen = @min(fields[0].len, type_buf.len - 1); + @memcpy(type_buf[0..tlen], fields[0][0..tlen]); + const type_cstr: [*:0]const u8 = @ptrCast(&type_buf); + + items_buf[count] = erts.makeTuple(env, .{ + erts.enif_make_atom(env, type_cstr), + label, + value, + frame, + }); + count += 1; + } + jni.releaseStringUTFChars(jenv, jresult, r); + } + jni.deleteLocalRef(jenv, jresult); + detachIfAttached(attached); + + return erts.makeList(env, items_buf[0..count]); +} + +// nif_ui_view_tree/0 — returns nested-map UI tree from MobBridge.uiViewTree(). +// +// Bridge contract: Kotlin returns a JSON string of the form +// {"type":"root","label":null,"value":null,"frame":[0,0,W,H],"children":[...]} +// parsed by Mob.Test.tree/1 (jason decode is fast; no need for a C-side +// JSON tokenizer). Returns {:error, :not_loaded} when MobBridge.uiViewTree() +// isn't present (early-adopter apps without registry). +export fn nif_ui_view_tree( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_view_tree == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_view_tree); + const result = jstringToBin(env, jenv, jresult); + detachIfAttached(attached); + return result; +} + +// nif_screen_info/0 — returns %{width, height, scale, safe_area: %{...}} +// +// Width/height are in dp (already px-divided by density on the Kotlin +// side). scale is the density factor (1.0/1.5/2.0/2.625/3.0/...) — same +// role as UIScreen.scale on iOS. +// +// Bridge contract: MobBridge.screenInfo() returns float[6+] = [w, h, +// scale, safe_top, safe_bottom, safe_left, safe_right]. Falls back to +// safe_area-only info if screenInfo() isn't bound (older bridges). +export fn nif_screen_info( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + var vals: [7]f32 = @splat(0); + if (Bridge.screen_info != null) { + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.screen_info); + if (arr != null) { + const got = jni.getArrayLength(jenv, arr); + const take: jni.JInt = if (got > 7) 7 else got; + jni.getFloatArrayRegion(jenv, arr, 0, take, &vals); + jni.deleteLocalRef(jenv, arr); + } + } + detachIfAttached(attached); + + const sa_keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "top"), + erts.atom(env, "bottom"), + erts.atom(env, "left"), + erts.atom(env, "right"), + }; + const sa_vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, @floatCast(vals[3])), + erts.enif_make_double(env, @floatCast(vals[4])), + erts.enif_make_double(env, @floatCast(vals[5])), + erts.enif_make_double(env, @floatCast(vals[6])), + }; + const safe_area = erts.makeMap(env, &sa_keys, &sa_vals) orelse erts.atom(env, "error"); + + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "width"), + erts.atom(env, "height"), + erts.atom(env, "scale"), + erts.atom(env, "safe_area"), + }; + const vvals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + safe_area, + }; + return erts.makeMap(env, &keys, &vvals) orelse erts.atom(env, "error"); +} + +// nif_ax_action/2 + nif_ax_action_at_xy/3 — Android stubs. +// +// Both are iOS-only today. Compose semantics walker (the proper Android +// implementation) is queued under WireTap (see future_developments.md). +// Return a clear error so callers get `{:error, :not_supported_on_android}` +// instead of an `:undef` crash. + +export fn nif_ax_action( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_supported_on_android"); +} + +export fn nif_ax_action_at_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_supported_on_android"); +} + +// nif_ui_debug/0 — returns raw uiTree string as a binary (for debugging). +export fn nif_ui_debug( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_tree == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_tree); + const result = jstringToBin(env, jenv, jresult); + detachIfAttached(attached); + return result; +} + +// nif_tap/1 — tap by accessibility label binary. +export fn nif_tap( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.tap_by_label == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + + // NewStringUTF takes a NUL-terminated C string; binary's data isn't + // NUL-terminated. Copy to a stack buffer for typical short labels; + // fall back to malloc on long ones. + var stack_buf: [512]u8 = undefined; + const use_heap = bin.size + 1 > stack_buf.len; + const heap_buf: ?*anyopaque = if (use_heap) jni.malloc(bin.size + 1) else null; + if (use_heap and heap_buf == null) return erts.atom(env, "error"); + const buf_ptr: [*]u8 = if (use_heap) @ptrCast(heap_buf) else &stack_buf; + defer if (use_heap) jni.free(heap_buf); + + @memcpy(buf_ptr[0..bin.size], bin.data[0..bin.size]); + buf_ptr[bin.size] = 0; + const label_cstr: [*:0]const u8 = @ptrCast(buf_ptr); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jlabel = jni.newStringUTF(jenv, label_cstr); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.tap_by_label, jlabel); + jni.deleteLocalRef(jenv, jlabel); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_element_with_label"); +} + +// nif_tap_xy/2 — tap at (x, y) dp coordinates. +export fn nif_tap_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.tap_xy == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.tap_xy, @as(f32, @floatCast(x)), @as(f32, @floatCast(y))); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} + +// nif_type_text/1 — type text into the focused view. +export fn nif_type_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.type_text == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + + var stack_buf: [4096]u8 = undefined; + const use_heap = bin.size + 1 > stack_buf.len; + const heap_buf: ?*anyopaque = if (use_heap) jni.malloc(bin.size + 1) else null; + if (use_heap and heap_buf == null) return erts.atom(env, "error"); + const buf_ptr: [*]u8 = if (use_heap) @ptrCast(heap_buf) else &stack_buf; + defer if (use_heap) jni.free(heap_buf); + + @memcpy(buf_ptr[0..bin.size], bin.data[0..bin.size]); + buf_ptr[bin.size] = 0; + const text_cstr: [*:0]const u8 = @ptrCast(buf_ptr); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text_cstr); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.type_text, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_delete_backward/0 — delete one character backward. +export fn nif_delete_backward( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.delete_backward == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.delete_backward); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_key_press/1 — not yet implemented on Android (no KeyCharacterMap lookup). +export fn nif_key_press( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_implemented"); +} + +// nif_clear_text/0 — select-all + delete in the focused view. +export fn nif_clear_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.clear_text == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.clear_text); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_long_press_xy/3 — long press at (x, y) for duration_ms milliseconds. +export fn nif_long_press_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.long_press_xy == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + var dur: c_int = 0; + if (erts.enif_get_int(env, argv[2], &dur) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?( + jenv, + Bridge.cls, + Bridge.long_press_xy, + @as(f32, @floatCast(x)), + @as(f32, @floatCast(y)), + @as(i64, @intCast(dur)), + ); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} + +// nif_swipe_xy/4 — swipe from (x1, y1) to (x2, y2) in dp. +export fn nif_swipe_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.swipe_xy == null) return notLoaded(env); + const x1 = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y1 = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + const x2 = erts.getNumber(env, argv[2]) orelse return erts.badarg(env); + const y2 = erts.getNumber(env, argv[3]) orelse return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?( + jenv, + Bridge.cls, + Bridge.swipe_xy, + @as(f32, @floatCast(x1)), + @as(f32, @floatCast(y1)), + @as(f32, @floatCast(x2)), + @as(f32, @floatCast(y2)), + ); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig index 8a045076..b41afcb3 100644 --- a/android/jni/mob_zig.zig +++ b/android/jni/mob_zig.zig @@ -71,6 +71,15 @@ pub extern fn closedir(dirp: *DIR) c_int; pub extern fn nanosleep(req: *const Timespec, rem: ?*Timespec) c_int; pub extern fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) c_int; +// libc allocator. We use `std.heap.c_allocator` in only one spot (test +// harness NIFs that copy a binary into a NUL-terminated buffer for +// NewStringUTF), and Zig 0.17 refuses to compile `std.heap.c_allocator` +// without `linkLibC()` on the module. Production builds link libc via +// the NDK clang link step anyway, so calling malloc/free directly is +// equivalent and skips the link-time guard. +pub extern fn malloc(size: usize) ?*anyopaque; +pub extern fn free(ptr: ?*anyopaque) void; + pub const _IONBF: c_int = 2; pub const FILE = opaque {}; @@ -179,7 +188,7 @@ pub const JNINativeInterface = extern struct { ExceptionDescribe: ?*anyopaque, // 17-22: exception finish, refs - ExceptionClear: ?*anyopaque, + ExceptionClear: ?*const fn (env: *JNIEnv) callconv(.c) void, FatalError: ?*anyopaque, PushLocalFrame: ?*anyopaque, PopLocalFrame: ?*anyopaque, @@ -187,7 +196,7 @@ pub const JNINativeInterface = extern struct { DeleteGlobalRef: ?*const fn (env: *JNIEnv, gref: JObject) callconv(.c) void, // 23-26: local ref slots - DeleteLocalRef: ?*anyopaque, + DeleteLocalRef: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) void, IsSameObject: ?*anyopaque, NewLocalRef: ?*anyopaque, EnsureLocalCapacity: ?*anyopaque, @@ -295,13 +304,15 @@ pub const JNINativeInterface = extern struct { SetFloatField: ?*anyopaque, SetDoubleField: ?*anyopaque, - // 114-152: static stuff + string ops — pad as opaque, we don't use them - // in mob_beam.zig (mob_nif iters will likely need GetStaticMethodID etc.). - GetStaticMethodID: ?*anyopaque, - CallStaticObjectMethod: ?*anyopaque, + // 114-152: GetStaticMethodID + CallStaticXxxMethod variants. Phase 6b + // iter 3b types the slots mob_nif.zig calls (GetStaticMethodID + the + // variadic ObjectMethod / BooleanMethod / VoidMethod); the rest stay + // opaque until a later iter needs them. + GetStaticMethodID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JMethodID, + CallStaticObjectMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) JObject, CallStaticObjectMethodV: ?*anyopaque, CallStaticObjectMethodA: ?*anyopaque, - CallStaticBooleanMethod: ?*anyopaque, + CallStaticBooleanMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) JBoolean, CallStaticBooleanMethodV: ?*anyopaque, CallStaticBooleanMethodA: ?*anyopaque, CallStaticByteMethod: ?*anyopaque, @@ -325,7 +336,7 @@ pub const JNINativeInterface = extern struct { CallStaticDoubleMethod: ?*anyopaque, CallStaticDoubleMethodV: ?*anyopaque, CallStaticDoubleMethodA: ?*anyopaque, - CallStaticVoidMethod: ?*anyopaque, + CallStaticVoidMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) void, CallStaticVoidMethodV: ?*anyopaque, CallStaticVoidMethodA: ?*anyopaque, GetStaticFieldID: ?*anyopaque, @@ -355,18 +366,64 @@ pub const JNINativeInterface = extern struct { GetStringLength: ?*anyopaque, GetStringChars: ?*anyopaque, ReleaseStringChars: ?*anyopaque, - NewStringUTF: ?*anyopaque, + NewStringUTF: ?*const fn (env: *JNIEnv, utf: [*:0]const u8) callconv(.c) JString, GetStringUTFLength: ?*anyopaque, // 169-170: GetStringUTFChars / ReleaseStringUTFChars — we use these GetStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, is_copy: ?*JBoolean) callconv(.c) ?[*:0]const u8, ReleaseStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, utf: [*:0]const u8) callconv(.c) void, - // The remaining ~60 slots (array ops, monitor enter/exit, GetJavaVM, - // NewWeakGlobalRef, etc.) are not used by mob_beam.zig — add when an - // iter needs them. Leaving them out is fine because we never read past - // the declared slots: as long as the layout up to the last USED slot - // matches jni.h, the unused tail can be anything. + // 171: GetArrayLength — typed (used by nif_screen_info). + GetArrayLength: ?*const fn (env: *JNIEnv, arr: JObject) callconv(.c) JInt, + + // 172-178: ObjectArray + primitive-array constructors — unused. + NewObjectArray: ?*anyopaque, + GetObjectArrayElement: ?*anyopaque, + SetObjectArrayElement: ?*anyopaque, + NewBooleanArray: ?*anyopaque, + NewByteArray: ?*anyopaque, + NewCharArray: ?*anyopaque, + NewShortArray: ?*anyopaque, + + // 179-187: more New*Array + Get*ArrayElements. + NewIntArray: ?*anyopaque, + NewLongArray: ?*anyopaque, + NewFloatArray: ?*anyopaque, + NewDoubleArray: ?*anyopaque, + GetBooleanArrayElements: ?*anyopaque, + GetByteArrayElements: ?*anyopaque, + GetCharArrayElements: ?*anyopaque, + GetShortArrayElements: ?*anyopaque, + GetIntArrayElements: ?*anyopaque, + + // 188-203: remaining Get*ArrayElements + all Release*ArrayElements + + // Get*ArrayRegion entries up through GetFloatArrayRegion. We need + // GetFloatArrayRegion (slot 203) typed for nif_screen_info / + // nif_safe_area; everything between stays opaque. + GetLongArrayElements: ?*anyopaque, + GetFloatArrayElements: ?*anyopaque, + GetDoubleArrayElements: ?*anyopaque, + ReleaseBooleanArrayElements: ?*anyopaque, + ReleaseByteArrayElements: ?*anyopaque, + ReleaseCharArrayElements: ?*anyopaque, + ReleaseShortArrayElements: ?*anyopaque, + ReleaseIntArrayElements: ?*anyopaque, + ReleaseLongArrayElements: ?*anyopaque, + ReleaseFloatArrayElements: ?*anyopaque, + ReleaseDoubleArrayElements: ?*anyopaque, + GetBooleanArrayRegion: ?*anyopaque, + GetByteArrayRegion: ?*anyopaque, + GetCharArrayRegion: ?*anyopaque, + GetShortArrayRegion: ?*anyopaque, + GetIntArrayRegion: ?*anyopaque, + GetLongArrayRegion: ?*anyopaque, + GetFloatArrayRegion: ?*const fn (env: *JNIEnv, arr: JObject, start: JInt, len: JInt, buf: [*]f32) callconv(.c) void, + + // The remaining ~30 slots (Get/Set*ArrayRegion tail, RegisterNatives, + // MonitorEnter/Exit, GetJavaVM, NewWeakGlobalRef, ExceptionCheck, + // DirectByteBuffer ops, GetObjectRefType) are not used by mob_nif.zig + // today. Add when a later iter needs them — the rule is "match jni.h + // up to the last USED slot". }; /// JavaVM vtable — used for GetEnv / AttachCurrentThread / DetachCurrentThread. @@ -428,6 +485,32 @@ pub inline fn newGlobalRef(env: *JNIEnv, obj: JObject) JObject { return env.*.NewGlobalRef.?(env, obj); } +// ── Static method helpers (added in iter 3b) ─────────────────────────────── + +pub inline fn getStaticMethodID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JMethodID { + return env.*.GetStaticMethodID.?(env, cls, name, sig); +} + +pub inline fn newStringUTF(env: *JNIEnv, utf: [*:0]const u8) JString { + return env.*.NewStringUTF.?(env, utf); +} + +pub inline fn deleteLocalRef(env: *JNIEnv, obj: JObject) void { + env.*.DeleteLocalRef.?(env, obj); +} + +pub inline fn exceptionClear(env: *JNIEnv) void { + env.*.ExceptionClear.?(env); +} + +pub inline fn getArrayLength(env: *JNIEnv, arr: JObject) JInt { + return env.*.GetArrayLength.?(env, arr); +} + +pub inline fn getFloatArrayRegion(env: *JNIEnv, arr: JObject, start: JInt, len: JInt, buf: [*]f32) void { + env.*.GetFloatArrayRegion.?(env, arr, start, len, buf); +} + pub inline fn getEnv(vm: *JavaVM, version: JInt) ?*JNIEnv { var env: ?*anyopaque = null; if (vm.*.GetEnv.?(vm, &env, version) != JNI_OK) return null; From 0aee424d9e23e334647843ca73b9420f2f1551cc Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 13:52:13 -0600 Subject: [PATCH 026/254] build_system_migration: Phase 6b iter 3b logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the test-harness + cached-Bridge + get_jenv move. iter 3b's key design choice was to relocate the shared infrastructure (Bridge struct + get_jenv) in the same iter as the test harness rather than spreading it across iter 3b/3c/3d — it would have forced three coordination events between the C and Zig versions of the cached struct otherwise, each of which is a silent failure mode if the field orders drift. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index cc0db270..1ceba3e0 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1176,13 +1176,60 @@ something useful even if the total project pauses. Android end-to-end smoke deploy deferred to bundle with the next sub-iter so we test once over a meaningful slice. - - iter 3b (planned): port the test harness NIFs — `ui_tree/0`, - `ui_view_tree/0`, `screen_info/0`, `tap/1`, `tap_xy/2`, - `type_text/1`, `delete_backward/0`, `key_press/1`, - `clear_text/0`, `long_press_xy/3`, `swipe_xy/4`. These are - largely independent of the cached MobBridge struct (they - look up their methods on demand or cache lazily) so they - slice cleanly. + - iter 3b (test harness + cached Bridge + get_jenv): the big + coordination move. Ported in one shot: + + • cached `BridgeMethods` extern struct (52 method-ID fields) + — moved to mob_nif.zig as `pub export var Bridge`; the C + side keeps a matching `struct BridgeMethods` declaration + + `extern struct BridgeMethods Bridge` so the senders (iter + 3c) and feature NIFs (iter 3d) still in C can read it. + Field order is load-bearing — any future change has to + land in both files together. + • `get_jenv` (the thread-attach helper that ~25 C-side + callers use) — moved with C-ABI export so existing call + sites are unaffected. + • 13 test harness NIFs: `ui_tree`, `ui_view_tree`, + `screen_info`, `ui_debug`, `ax_action{,_at_xy}` (Android + stubs), `tap`, `tap_xy`, `type_text`, `delete_backward`, + `key_press` (Android stub), `clear_text`, `long_press_xy`, + `swipe_xy`. Coordinates in dp, matching iOS. + • `jstring_to_bin` / `cstr_to_bin` helpers (Zig-private — + only the test harness used them). + + FFI binding extensions: + + • mob_zig.zig: typed previously-opaque JNI vtable slots + (GetStaticMethodID, CallStaticObjectMethod / BooleanMethod + / VoidMethod as variadic, NewStringUTF, DeleteLocalRef, + ExceptionClear, GetArrayLength, GetFloatArrayRegion). + Added padding for the intervening Array* slots so the + layout up to GetFloatArrayRegion matches AOSP jni.h + slot-for-slot. Wrappers (getStaticMethodID, newStringUTF, + deleteLocalRef, exceptionClear, getArrayLength, + getFloatArrayRegion) + extern malloc/free for the + unbounded-binary path in nif_tap / nif_type_text. + • mob_erts.zig: enif_make_list_cell, enif_make_list_from_array, + enif_make_tuple_from_array, enif_make_map_from_arrays, + enif_alloc_binary, enif_inspect_iolist_as_binary, + enif_get_int, enif_get_double + convenience wrappers + (makeTuple, makeList, makeMap, errorTuple, getNumber). + + mob_nif.c net change: -409 lines (the static Bridge struct, + get_jenv, jstring_to_bin/cstr_to_bin, 13 NIF defs replaced + by the named-struct declaration, extern block expansion, and + a pointer comment). + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with 19 + exported symbols (Bridge, get_jenv, 17 nif_* — 3 from iter 3a + + 13 test harness + 1 ax_action_at_xy stub) and 20 undefined + refs that all resolve at production link (enif_* / libc / liblog + / g_jvm from mob_beam.zig). mob_nif.c passes clang-format. + 702/702 mob tests + 224/224 mob_new tests + credo strict + clean. mob_new template needs no change this iter — mob_nif.zig + source spec was wired in iter 3a and new NIFs are internal + to that file. - iter 3c (planned): the concurrency-heavy core — cached MobBridge method ID struct, tap/component handle registries, per-handle throttle state, all `mob_send_*` event senders. From 13fcabc20cbc16bdbe963adbc928ea0ed209563c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 14:04:02 -0600 Subject: [PATCH 027/254] Phase 6b iter 3c (mob side): port senders + handle registries to Zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency-heavy core. mob_nif.c is now 1500 lines (down from 2568 at iter 3a start — 41% reduction). 56% of the native code is Zig now. Moved to mob_nif.zig: * Handle registries: TapHandle struct (with per-handle throttle state) + tap_handles[MAX_TAP_HANDLES] + tap_handle_next + tap_mutex; ComponentHandle struct + component_handles + component_mutex. g_transition (the per-render transition snapshot, set by nif_set_transition and consumed by nif_set_root). All previously file-scope C statics. * mob_nif_init_state — exported initializer that nif_load (still in C) calls during BEAM init to create both mutexes. Mutex create-or-die replaces the inline enif_mutex_create pair that used to live in nif_load. * snapTap + sendEvent + sendChange — internal Zig helpers factored out of the C original's send_event / send_change pattern. Locks the mutex, snapshots pid + tag + seq, drops the lock before delivering the message (so we never hold the mutex across a potentially-blocking enif_send). * 25 sender functions (called from beam_jni.c JNI stubs): - mob_send_tap, mob_send_component_event - mob_send_change_str / _bool / _float - mob_send_focus / _blur / _submit / _select / _compose - mob_send_long_press / _double_tap / _swipe_{left,right,up, down} / _swipe_with_direction - mob_send_scroll / _drag / _pinch / _rotate / _pointer_move (with the Batch-5 Tier-1 throttle gating + sequence-number bump on each successful emit) - mob_send_scroll_began / _ended / _settled / _top_reached / _scrolled_past (Tier-2 single-fire) - mob_handle_back (back-gesture → {:mob, :back} to the :mob_screen registered process) * 6 NIFs that touch the registries: - nif_set_root (snapshots + resets g_transition, then forwards to MobBridge.setRootJson via JNI) - nif_register_tap (allocates tag_env, copies in the tag) - nif_clear_taps (frees tag_env, zeros throttle state) - nif_set_transition - nif_register_component / nif_deregister_component * Throttle infrastructure: throttleCheck (replaces C mob_throttle_check_a — locks the mutex, applies the per-handle throttle_ms / delta_threshold gates, bumps seq), buildScrollMap (parallel-arrays-to-map helper for scroll/drag payloads), isPhaseBoundary (lets began/ended bypass throttle so the BEAM always sees gesture boundaries). FFI binding extensions: * mob_erts.zig: enif_send, enif_self, enif_make_copy, enif_alloc_env, enif_free_env, enif_mutex_create / _lock / _unlock, enif_get_local_pid, enif_whereis_pid, enif_make_int64 / _uint64, enif_get_tuple. Needed by every sender that hops a term across a process boundary. * mob_zig.zig: clock_gettime + CLOCK_MONOTONIC + a nowNs() wrapper for the throttle path's monotonic timestamps. mob_nif.c lost ~666 lines net. The static registry + sender block (515 lines) plus the 6 NIF defs + the inline mutex-create were deleted; replaced by a short pointer-comment block + the mob_nif_init_state extern + the 6 NIF externs at the top. Verified: standalone `zig build-obj -target aarch64-linux-android.24` produces a clean mob_nif.o with 55 exported symbols (Bridge, get_jenv, mob_nif_init_state, 25 mob_send_*, mob_handle_back, 23 nif_*). All undefined refs (enif_* / clock_gettime / __android_log_print / malloc/free/memcpy/memset/strlen / g_jvm) resolve at production link. 702/702 mob tests + credo strict clean. clang-format clean. Full Android end-to-end smoke deploy still deferred — bundles best with iter 3d's NIF-table move + mob_nif.c deletion so we test once over the whole completed port. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_erts.zig | 35 ++ android/jni/mob_nif.c | 687 ++--------------------------------- android/jni/mob_nif.zig | 757 ++++++++++++++++++++++++++++++++++++++- android/jni/mob_zig.zig | 14 + 4 files changed, 825 insertions(+), 668 deletions(-) diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index f4128431..540d0bf1 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -101,6 +101,41 @@ pub extern fn enif_make_map_from_arrays( // BEAM owns it. pub extern fn enif_alloc_binary(size: usize, bin: *ErlNifBinary) c_int; +// 64-bit integer constructors (iter 3c). Used by the throttled gesture/ +// scroll/drag/pinch senders for monotonic timestamps and sequence numbers. +pub extern fn enif_make_int64(env: ?*ErlNifEnv, i: i64) ERL_NIF_TERM; +pub extern fn enif_make_uint64(env: ?*ErlNifEnv, i: u64) ERL_NIF_TERM; + +// Term-env hop (iter 3c). enif_send delivers a message to a pid; the +// `msg_env` must be a "process-independent" env allocated via +// enif_alloc_env / freed via enif_free_env after the send returns. +// Terms in `msg_env` must originate there or be copied in via +// enif_make_copy. +pub extern fn enif_alloc_env() ?*ErlNifEnv; +pub extern fn enif_free_env(env: ?*ErlNifEnv) void; +pub extern fn enif_make_copy(dst: ?*ErlNifEnv, src_term: ERL_NIF_TERM) ERL_NIF_TERM; +pub extern fn enif_send( + caller_env: ?*ErlNifEnv, + to_pid: *const ErlNifPid, + msg_env: ?*ErlNifEnv, + msg: ERL_NIF_TERM, +) c_int; +pub extern fn enif_self(caller_env: ?*ErlNifEnv, pid: *ErlNifPid) ?*ErlNifPid; + +// Pid resolution (iter 3c). +pub extern fn enif_get_local_pid(env: ?*ErlNifEnv, term: ERL_NIF_TERM, pid: *ErlNifPid) c_int; +pub extern fn enif_whereis_pid(env: ?*ErlNifEnv, name: ERL_NIF_TERM, pid: *ErlNifPid) c_int; + +// Tuple inspectors (iter 3c). +pub extern fn enif_get_tuple(env: ?*ErlNifEnv, tpl: ERL_NIF_TERM, arity: *c_int, array: *[*]const ERL_NIF_TERM) c_int; + +// Mutex (iter 3c). enif_mutex_create allocates; destroy + try-lock omitted +// — Mob only uses simple lock/unlock pairs and the mutexes live for the +// lifetime of the BEAM process (no destroy needed). +pub extern fn enif_mutex_create(name: [*:0]const u8) ?*ErlNifMutex; +pub extern fn enif_mutex_lock(mtx: ?*ErlNifMutex) void; +pub extern fn enif_mutex_unlock(mtx: ?*ErlNifMutex) void; + // ── Term inspectors ─────────────────────────────────────────────────────── /// Returns 1 on success, 0 on failure. Fills `bin` with the binary's diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c index 7a19ee09..3915516a 100644 --- a/android/jni/mob_nif.c +++ b/android/jni/mob_nif.c @@ -119,521 +119,26 @@ extern struct BridgeMethods Bridge; // itself is now exported with C ABI from Zig. extern JNIEnv *get_jenv(int *attached); -// ── Tap handle registry ─────────────────────────────────────────────────────── -// Cleared before every render. Max 256 tappable elements per frame. +// ── Senders + handle registries + 6 NIFs moved to mob_nif.zig (iter 3c) ───── +// The full mob_send_* family (tap, change_str/bool/float, focus/blur/submit/ +// select/compose, gesture senders, throttled scroll/drag/pinch/rotate/ +// pointer_move, scroll-began/ended/settled, swipe_with_direction, back, +// component_event) and the TapHandle / ComponentHandle registries with their +// mutexes are now in Zig. Six NIFs that touched those statics moved with +// them: set_root, register_tap, clear_taps, set_transition, register_component, +// deregister_component. // -// Each handle stores a pid and an optional tag term (copied into a persistent -// NIF env). When tapped, sends {:tap, tag} to pid. -// Backwards compat: register_tap(pid) stores tag = :ok. - -#define MAX_TAP_HANDLES 256 - -typedef struct { - ErlNifPid pid; - ErlNifEnv *tag_env; // persistent env owning tag; NULL when not in use - ERL_NIF_TERM tag; // the term sent as the second element of {:tap, tag} - - // ── Batch 5 throttle state — populated by mob_set_throttle_config ── - int throttle_ms; - int debounce_ms; - double delta_threshold; - int leading; - int trailing; - long long last_emit_ns; // CLOCK_MONOTONIC ns - double last_x; - double last_y; - unsigned long long seq; -} TapHandle; - -static TapHandle tap_handles[MAX_TAP_HANDLES]; -static int tap_handle_next = 0; -static ErlNifMutex *tap_mutex = NULL; -static char g_transition[16] = "none"; // set by set_transition/1, read+reset by set_root/1 - -// ── Component handle registry ───────────────────────────────────────────────── -// Persistent (not cleared between renders). Each slot maps an integer handle to -// a component process pid. register_component/1 allocates; deregister_component/1 frees. - -#define MAX_COMPONENT_HANDLES 64 - -typedef struct { - ErlNifPid pid; - int active; -} ComponentHandle; - -static ComponentHandle component_handles[MAX_COMPONENT_HANDLES]; -static ErlNifMutex *component_mutex = NULL; - -void mob_send_component_event(int handle, const char *event, const char *payload_json) { - if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) - return; - enif_mutex_lock(component_mutex); - if (!component_handles[handle].active) { - enif_mutex_unlock(component_mutex); - return; - } - ErlNifPid pid = component_handles[handle].pid; - enif_mutex_unlock(component_mutex); - - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "component_event"), - enif_make_string(env, event, ERL_NIF_LATIN1), - enif_make_string(env, payload_json, ERL_NIF_LATIN1)); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// Called from the app's Java_..._MobBridge_nativeSendTap JNI stub -// (declared in mob_beam.h, defined here). -void mob_send_tap(int handle) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ErlNifEnv *tag_env = tap_handles[handle].tag_env; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv *msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = - enif_make_tuple2(msg_env, enif_make_atom(msg_env, "tap"), enif_make_copy(msg_env, tag)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); - (void)tag_env; // owned by tap_handles; freed in clear_taps -} - -// ── Change senders ──────────────────────────────────────────────────────────── -// Called from beam_jni.c JNI stubs when an input widget fires an onChange event. -// Each builds {:change, tag, value} and sends it to the registered pid. - -static void send_change(int handle, ERL_NIF_TERM value_term) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv *msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = - enif_make_tuple3(msg_env, enif_make_atom(msg_env, "change"), enif_make_copy(msg_env, tag), - enif_make_copy(msg_env, value_term)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -void mob_send_change_str(int handle, const char *utf8) { - ErlNifEnv *tmp = enif_alloc_env(); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - ERL_NIF_TERM term = enif_make_binary(tmp, &bin); - send_change(handle, term); - enif_free_env(tmp); -} - -void mob_send_change_bool(int handle, int bool_val) { - ErlNifEnv *tmp = enif_alloc_env(); - ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); - send_change(handle, term); - enif_free_env(tmp); -} - -void mob_send_change_float(int handle, double value) { - ErlNifEnv *tmp = enif_alloc_env(); - ERL_NIF_TERM term = enif_make_double(tmp, value); - send_change(handle, term); - enif_free_env(tmp); -} - -// ── Focus / blur / submit senders ──────────────────────────────────────────── -// Called from beam_jni.c JNI stubs when a text field gains/loses focus or -// the return key is pressed. Sends a {:event, tag} 2-tuple to the registered pid. - -static void send_event(int handle, const char *atom) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv *msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = - enif_make_tuple2(msg_env, enif_make_atom(msg_env, atom), enif_make_copy(msg_env, tag)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -void mob_send_focus(int handle) { - send_event(handle, "focus"); -} -void mob_send_blur(int handle) { - send_event(handle, "blur"); -} -void mob_send_submit(int handle) { - send_event(handle, "submit"); -} -void mob_send_select(int handle) { - send_event(handle, "select"); -} - -// IME composition. Sends {compose, tag, %{text, phase}} where phase is -// began/updating/committed/cancelled. Called from beam_jni.c when the -// Compose TextField's TextFieldValue.composition range changes, or from -// an InputConnection observer in legacy view-system text fields. -void mob_send_compose(int handle, const char *text, const char *phase) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM keys[2] = { - enif_make_atom(env, "text"), - enif_make_atom(env, "phase"), - }; - ERL_NIF_TERM vals[2] = { - enif_make_string(env, text ? text : "", ERL_NIF_LATIN1), - enif_make_atom(env, phase), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 2, &payload); - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "compose"), enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// ── Gesture senders (Batch 4) ─────────────────────────────────────────────── -// Called from beam_jni.c when the Compose gesture detector fires. Each is -// per-widget opt-in — only registered handles emit. Direction-aware swipes -// use mob_send_swipe_with_direction. - -void mob_send_long_press(int handle) { - send_event(handle, "long_press"); -} -void mob_send_double_tap(int handle) { - send_event(handle, "double_tap"); -} -void mob_send_swipe_left(int handle) { - send_event(handle, "swipe_left"); -} -void mob_send_swipe_right(int handle) { - send_event(handle, "swipe_right"); -} -void mob_send_swipe_up(int handle) { - send_event(handle, "swipe_up"); -} -void mob_send_swipe_down(int handle) { - send_event(handle, "swipe_down"); -} - -void mob_send_swipe_with_direction(int handle, const char *direction) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv *msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = - enif_make_tuple3(msg_env, enif_make_atom(msg_env, "swipe"), enif_make_copy(msg_env, tag), - enif_make_atom(msg_env, direction)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -// ── Batch 5 Tier 1: high-frequency events with throttling ───────────────── -// Mirrors the iOS implementation in mob_nif.m. Throttle state lives on each -// TapHandle (above). JNI stubs in beam_jni.c are pending — these C functions -// are the bridge target. - -static long long mob_now_ns_android(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (long long)ts.tv_sec * 1000000000LL + (long long)ts.tv_nsec; -} - -void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, double delta_threshold, - int leading, int trailing) { - enif_mutex_lock(tap_mutex); - if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { - tap_handles[handle].throttle_ms = throttle_ms; - tap_handles[handle].debounce_ms = debounce_ms; - tap_handles[handle].delta_threshold = delta_threshold; - tap_handles[handle].leading = leading; - tap_handles[handle].trailing = trailing; - } - enif_mutex_unlock(tap_mutex); -} - -static int mob_throttle_check_a(int handle, double x, double y, int default_throttle_ms, - double default_delta) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return 0; - } - TapHandle *h = &tap_handles[handle]; - int throttle_ms = h->throttle_ms ? h->throttle_ms : default_throttle_ms; - double delta_threshold = h->delta_threshold > 0 ? h->delta_threshold : default_delta; - - long long now_ns = mob_now_ns_android(); - double dx = x - h->last_x; - double dy = y - h->last_y; - double dist = (dx < 0 ? -dx : dx) + (dy < 0 ? -dy : dy); - - if (h->last_emit_ns > 0 && throttle_ms > 0) { - long long elapsed_ms = (now_ns - h->last_emit_ns) / 1000000LL; - if (elapsed_ms < throttle_ms) { - enif_mutex_unlock(tap_mutex); - return 0; - } - } - if (h->last_emit_ns > 0 && dist < delta_threshold) { - enif_mutex_unlock(tap_mutex); - return 0; - } - - h->last_emit_ns = now_ns; - h->last_x = x; - h->last_y = y; - h->seq++; - enif_mutex_unlock(tap_mutex); - return 1; -} - -// Build payload map for scroll/drag/etc. Caller owns msg_env. -static ERL_NIF_TERM mob_build_scroll_map(ErlNifEnv *env, double x, double y, double dx, double dy, - double vx, double vy, const char *phase, long long ts_ms, - unsigned long long seq) { - ERL_NIF_TERM keys[9] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), - enif_make_atom(env, "velocity_x"), enif_make_atom(env, "velocity_y"), - enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), - enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[9] = { - enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), - enif_make_double(env, dy), enif_make_double(env, vx), enif_make_double(env, vy), - enif_make_atom(env, phase), enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM map; - enif_make_map_from_arrays(env, keys, vals, 9, &map); - return map; -} - -void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, double vy, - const char *phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 33, 1.0)) - return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM payload = mob_build_scroll_map(env, x, y, dx, dy, vx, vy, phase, ts_ms, seq); - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "scroll"), enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 16, 1.0)) - return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM keys[7] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), enif_make_atom(env, "dx"), - enif_make_atom(env, "dy"), enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), - enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[7] = { - enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), - enif_make_double(env, dy), enif_make_atom(env, phase), enif_make_int64(env, ts_ms), - enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 7, &payload); - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "drag"), enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_pinch(int handle, double scale, double velocity, const char *phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, scale, 0, 16, 0.01)) - return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "scale"), enif_make_atom(env, "velocity"), enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[5] = { - enif_make_double(env, scale), enif_make_double(env, velocity), enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "pinch"), enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_rotate(int handle, double degrees, double velocity, const char *phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, degrees, 0, 16, 1.0)) - return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "degrees"), enif_make_atom(env, "velocity"), - enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), - enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[5] = { - enif_make_double(env, degrees), enif_make_double(env, velocity), enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "rotate"), enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_pointer_move(int handle, double x, double y) { - if (!mob_throttle_check_a(handle, x, y, 33, 4.0)) - return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv *env = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "x"), - enif_make_atom(env, "y"), - enif_make_atom(env, "ts"), - enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(env, x), - enif_make_double(env, y), - enif_make_int64(env, ts_ms), - enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 4, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "pointer_move"), - enif_make_copy(env, tag), payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// ── Batch 5 Tier 2: semantic single-fire scroll events ── -void mob_send_scroll_began(int handle) { - send_event(handle, "scroll_began"); -} -void mob_send_scroll_ended(int handle) { - send_event(handle, "scroll_ended"); -} -void mob_send_scroll_settled(int handle) { - send_event(handle, "scroll_settled"); -} -void mob_send_top_reached(int handle) { - send_event(handle, "top_reached"); -} -void mob_send_scrolled_past(int handle) { - send_event(handle, "scrolled_past"); -} - -// ── Back gesture sender ─────────────────────────────────────────────────────── -// Called from beam_jni.c's nativeHandleBack JNI stub when the Android back -// gesture fires. Looks up the :mob_screen registered process and sends -// {:mob, :back} — Mob.Screen.handle_info/2 handles popping or exiting. - -void mob_handle_back(void) { - ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = - enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); - enif_send(NULL, &pid, env, msg); - } - enif_free_env(env); -} +// nif_load (below) calls mob_nif_init_state() — also exported from +// mob_nif.zig — to create the mutexes during BEAM init. +extern int mob_nif_init_state(void); + +// iter 3c — registry NIFs: +extern ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +extern ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); // ── JNI helpers (get_jenv moved to mob_nif.zig in iter 3b) ────────────────── @@ -740,100 +245,8 @@ static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TER // Accepts a JSON binary and passes it to MobBridge.setRootJson(String) on the // Kotlin side. Compose state update is thread-safe — no main-thread hop needed. -static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - - // Null-terminate for NewStringUTF - char *json = (char *)malloc(bin.size + 1); - if (!json) - return enif_make_atom(env, "error"); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - - // Snapshot the current transition (set by set_transition/1 before this call) - enif_mutex_lock(tap_mutex); - char transition[16]; - strncpy(transition, g_transition, sizeof(transition) - 1); - transition[sizeof(transition) - 1] = 0; - strncpy(g_transition, "none", sizeof(g_transition)); // reset to none - enif_mutex_unlock(tap_mutex); - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jjson = (*jenv)->NewStringUTF(jenv, json); - jstring jtransition = (*jenv)->NewStringUTF(jenv, transition); - free(json); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.set_root, jjson, jtransition); - (*jenv)->DeleteLocalRef(jenv, jjson); - (*jenv)->DeleteLocalRef(jenv, jtransition); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: register_tap/1 ────────────────────────────────────────────────────── -// Accepts pid (tag = :ok) or {pid, tag} (any Erlang term used as the tag). - -static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - ERL_NIF_TERM tag_term; - - // Try plain pid first - if (enif_get_local_pid(env, argv[0], &pid)) { - // No explicit tag — use :ok - tag_term = enif_make_atom(env, "ok"); - } else { - // Try {pid, tag} 2-tuple - int arity; - const ERL_NIF_TERM *elems; - if (!enif_get_tuple(env, argv[0], &arity, &elems) || arity != 2) - return enif_make_badarg(env); - if (!enif_get_local_pid(env, elems[0], &pid)) - return enif_make_badarg(env); - tag_term = elems[1]; - } - - enif_mutex_lock(tap_mutex); - if (tap_handle_next >= MAX_TAP_HANDLES) { - enif_mutex_unlock(tap_mutex); - return enif_make_badarg(env); - } - int handle = tap_handle_next++; - tap_handles[handle].pid = pid; - tap_handles[handle].tag_env = enif_alloc_env(); - tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); - enif_mutex_unlock(tap_mutex); - - return enif_make_int(env, handle); -} - -// ── NIF: clear_taps/0 ──────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - enif_mutex_lock(tap_mutex); - for (int i = 0; i < tap_handle_next; i++) { - if (tap_handles[i].tag_env) { - enif_free_env(tap_handles[i].tag_env); - tap_handles[i].tag_env = NULL; - } - // Reset throttle state — slots get reused across renders. - tap_handles[i].throttle_ms = 0; - tap_handles[i].debounce_ms = 0; - tap_handles[i].delta_threshold = 0; - tap_handles[i].leading = 1; - tap_handles[i].trailing = 1; - tap_handles[i].last_emit_ns = 0; - tap_handles[i].last_x = 0; - tap_handles[i].last_y = 0; - tap_handles[i].seq = 0; - } - tap_handle_next = 0; - enif_mutex_unlock(tap_mutex); - return enif_make_atom(env, "ok"); -} +// nif_set_root / nif_register_tap / nif_clear_taps / nif_set_transition +// moved to mob_nif.zig (iter 3c). // ── NIF: exit_app/0 ────────────────────────────────────────────────────────── // Backgrounds the app via MobBridge.moveToBack() → activity.moveTaskToBack(true). @@ -848,20 +261,6 @@ static ERL_NIF_TERM nif_exit_app(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar return enif_make_atom(env, "ok"); } -// ── NIF: set_transition/1 ──────────────────────────────────────────────────── -// Stores the transition type atom (push/pop/reset/none) to be passed to -// setRootJson on the next set_root call. Must be called before set_root. - -static ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - enif_mutex_lock(tap_mutex); - if (!enif_get_atom(env, argv[0], g_transition, sizeof(g_transition), ERL_NIF_LATIN1)) { - enif_mutex_unlock(tap_mutex); - return enif_make_badarg(env); - } - enif_mutex_unlock(tap_mutex); - return enif_make_atom(env, "ok"); -} - // ── NIF: safe_area/0 ───────────────────────────────────────────────────────── // Returns {Top, Right, Bottom, Left} in dp via MobBridge.getSafeArea(). @@ -1761,35 +1160,8 @@ static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_ } // ── Native view component NIFs ──────────────────────────────────────────────── - -static ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) - return enif_make_badarg(env); - - enif_mutex_lock(component_mutex); - for (int i = 0; i < MAX_COMPONENT_HANDLES; i++) { - if (!component_handles[i].active) { - component_handles[i].pid = pid; - component_handles[i].active = 1; - enif_mutex_unlock(component_mutex); - return enif_make_int(env, i); - } - } - enif_mutex_unlock(component_mutex); - return enif_make_badarg(env); -} - -static ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int handle; - if (!enif_get_int(env, argv[0], &handle) || handle < 0 || handle >= MAX_COMPONENT_HANDLES) - return enif_make_badarg(env); - - enif_mutex_lock(component_mutex); - component_handles[handle].active = 0; - enif_mutex_unlock(component_mutex); - return enif_make_atom(env, "ok"); -} +// nif_register_component / nif_deregister_component moved to mob_nif.zig +// (iter 3c) alongside the ComponentHandle registry they touch. // ── NIF: background_keep_alive/0, background_stop/0 ───────────────────────── @@ -1977,14 +1349,11 @@ static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { return -1; } - tap_mutex = enif_mutex_create("mob_tap_mutex"); - if (!tap_mutex) { - LOGE("nif_load: failed to create tap mutex"); - return -1; - } - component_mutex = enif_mutex_create("mob_component_mutex"); - if (!component_mutex) { - LOGE("nif_load: failed to create component mutex"); + // tap_mutex + component_mutex are defined in mob_nif.zig (iter 3c). + // mob_nif_init_state creates both. Returns 0 on success, -1 on failure + // (enif_mutex_create returned NULL) — matches our nif_load return code. + if (mob_nif_init_state() != 0) { + LOGE("nif_load: mob_nif_init_state failed (mutex create)"); return -1; } diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index c474f03c..3ef337ea 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -9,15 +9,20 @@ //! Sub-iter sequence: //! * iter 3a: 3 standalone NIFs — platform/0, log/1, log/2. No JNI, no //! shared state. Proved the cross-language linkage pattern. -//! * iter 3b (this iter): test harness NIFs (ui_tree, ui_view_tree, -//! screen_info, tap, tap_xy, type_text, delete_backward, key_press, -//! clear_text, long_press_xy, swipe_xy, ax_action stubs, ui_debug) -//! + the cached `Bridge` MobBridge method-ID struct + `get_jenv` (the -//! thread-attach helper). Moving Bridge/get_jenv here unblocks the -//! remaining sub-iters — both senders (iter 3c) and the feature NIFs -//! (iter 3d) reach into the same struct. -//! * iter 3c: event senders + tap/component handle registries + -//! per-handle throttle state. +//! * iter 3b: test harness NIFs (ui_tree, ui_view_tree, screen_info, +//! tap, tap_xy, type_text, delete_backward, key_press, clear_text, +//! long_press_xy, swipe_xy, ax_action stubs, ui_debug) + the cached +//! `Bridge` MobBridge method-ID struct + `get_jenv` (the thread- +//! attach helper). +//! * iter 3c (this iter): event senders (mob_send_* family — tap, +//! change, focus/blur/submit/select/compose, gestures, throttled +//! scroll/drag/pinch/rotate/pointer_move, scroll-began/ended/settled, +//! back), tap + component handle registries with their mutexes, +//! per-handle throttle state, and the 6 NIFs that touch these +//! statics (nif_set_root, nif_register_tap, nif_clear_taps, +//! nif_set_transition, nif_register_component, nif_deregister_component). +//! The C-side `nif_load` calls `mob_nif_init_state` (exported here) +//! to create the mutexes during BEAM init. //! * iter 3d: remaining feature NIFs (storage, WebView, alert, //! action_sheet, toast, native view components, lifecycle, //! Mob.Device). Moves the NIF table itself here. mob_nif.c deleted. @@ -679,3 +684,737 @@ export fn nif_swipe_xy( detachIfAttached(attached); return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); } + +// ── Handle registries (Phase 6b iter 3c) ───────────────────────────────── +// +// Two pools of per-widget routing slots. The tap registry is cleared every +// render frame (clear_taps); the component registry is persistent — slots +// stay live across renders and are explicitly freed by deregister_component. +// +// Both pools sit behind mutexes. The mutexes are created lazily by +// mob_nif_init_state (called from mob_nif.c's nif_load BEAM callback). + +const MAX_TAP_HANDLES: usize = 256; +const MAX_COMPONENT_HANDLES: usize = 64; + +/// Per-tap slot: the registered pid, an optional caller-supplied tag, and +/// the throttle state for high-frequency events. tag_env is non-null while +/// the slot is in use; clear_taps frees it and nulls it back out. +const TapHandle = extern struct { + pid: erts.ErlNifPid, + tag_env: ?*erts.ErlNifEnv, + tag: erts.ERL_NIF_TERM, + + // ── Batch 5 throttle state — populated by mob_set_throttle_config ── + throttle_ms: c_int, + debounce_ms: c_int, + delta_threshold: f64, + leading: c_int, + trailing: c_int, + last_emit_ns: i64, + last_x: f64, + last_y: f64, + seq: u64, +}; + +const ComponentHandle = extern struct { + pid: erts.ErlNifPid, + active: c_int, +}; + +var tap_handles: [MAX_TAP_HANDLES]TapHandle = @splat(std.mem.zeroes(TapHandle)); +var tap_handle_next: c_int = 0; +var tap_mutex: ?*erts.ErlNifMutex = null; +/// Snapshotted by nif_set_root; written by nif_set_transition. Guarded by +/// tap_mutex (the C original reused that mutex rather than allocating a +/// second one — keep the lock geometry the same). +var g_transition: [16]u8 = blk: { + var buf: [16]u8 = @splat(0); + buf[0] = 'n'; + buf[1] = 'o'; + buf[2] = 'n'; + buf[3] = 'e'; + break :blk buf; +}; + +var component_handles: [MAX_COMPONENT_HANDLES]ComponentHandle = @splat(std.mem.zeroes(ComponentHandle)); +var component_mutex: ?*erts.ErlNifMutex = null; + +/// Initialise both mutexes. Called from mob_nif.c's nif_load BEAM callback +/// — must run once before any sender or NIF that locks them. Returns 0 +/// on success, -1 on failure (matches the C nif_load return convention). +pub export fn mob_nif_init_state() callconv(.c) c_int { + tap_mutex = erts.enif_mutex_create("mob_tap_mutex") orelse return -1; + component_mutex = erts.enif_mutex_create("mob_component_mutex") orelse return -1; + return 0; +} + +// ── Sender helpers ─────────────────────────────────────────────────────── +// All senders share the same shape: lock tap_mutex, validate the handle +// is in use (slot index in range AND tag_env non-null), copy the pid + tag +// out under the lock, then build and deliver the message to that pid in a +// freshly allocated env. The lock is dropped before enif_send so we don't +// hold it across a potentially-blocking send. + +/// Snapshot a TapHandle's routing under the tap_mutex. Returns null if +/// the handle is unused/out of range. The boolean flag pulls seq too — +/// only the throttled-event senders care about that. +const TapSnap = struct { + pid: erts.ErlNifPid, + tag: erts.ERL_NIF_TERM, + seq: u64, +}; + +fn snapTap(handle: c_int) ?TapSnap { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_handle_next) return null; + const h = &tap_handles[@intCast(handle)]; + if (h.tag_env == null) return null; + return TapSnap{ .pid = h.pid, .tag = h.tag, .seq = h.seq }; +} + +/// `{:event, tag}` — used by focus/blur/submit/select and the gesture +/// senders that don't carry a payload. +fn sendEvent(handle: c_int, comptime atom_name: [:0]const u8) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, atom_name.ptr), + erts.enif_make_copy(env, snap.tag), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `{:change, tag, value}` — used by the three change senders below. The +/// value term must originate in the same env we're delivering through. +fn sendChange(handle: c_int, value_term: erts.ERL_NIF_TERM) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "change"), + erts.enif_make_copy(env, snap.tag), + erts.enif_make_copy(env, value_term), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Tap + change senders ──────────────────────────────────────────────── + +/// Called from beam_jni.c's `nativeSendTap` JNI stub. Sends `{:tap, tag}` +/// to the pid registered for `handle`. +pub export fn mob_send_tap(handle: c_int) callconv(.c) void { + sendEvent(handle, "tap"); +} + +pub export fn mob_send_change_str(handle: c_int, utf8: [*:0]const u8) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + var bin: erts.ErlNifBinary = undefined; + const len = std.mem.span(utf8).len; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf8[0..len]); + const term = erts.enif_make_binary(tmp, &bin); + sendChange(handle, term); +} + +pub export fn mob_send_change_bool(handle: c_int, bool_val: c_int) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + const term = erts.enif_make_atom(tmp, if (bool_val != 0) "true" else "false"); + sendChange(handle, term); +} + +pub export fn mob_send_change_float(handle: c_int, value: f64) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + const term = erts.enif_make_double(tmp, value); + sendChange(handle, term); +} + +// ── Focus / blur / submit / select / compose ──────────────────────────── + +pub export fn mob_send_focus(handle: c_int) callconv(.c) void { + sendEvent(handle, "focus"); +} +pub export fn mob_send_blur(handle: c_int) callconv(.c) void { + sendEvent(handle, "blur"); +} +pub export fn mob_send_submit(handle: c_int) callconv(.c) void { + sendEvent(handle, "submit"); +} +pub export fn mob_send_select(handle: c_int) callconv(.c) void { + sendEvent(handle, "select"); +} + +/// `{:compose, tag, %{text, phase}}` — IME composition events. phase is +/// began | updating | committed | cancelled (the latter two are terminal). +pub export fn mob_send_compose(handle: c_int, text: ?[*:0]const u8, phase: [*:0]const u8) callconv(.c) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const text_cstr: [*:0]const u8 = text orelse ""; + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "text"), + erts.enif_make_atom(env, "phase"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_string(env, text_cstr, erts.ERL_NIF_LATIN1), + erts.enif_make_atom(env, phase), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "compose"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Gesture senders (Batch 4) ─────────────────────────────────────────── +// Per-widget opt-in — only handles with a registered tag emit. Direction- +// aware swipes go through mob_send_swipe_with_direction; the legacy fixed +// directions stay around for any beam_jni.c stubs that haven't migrated. + +pub export fn mob_send_long_press(handle: c_int) callconv(.c) void { + sendEvent(handle, "long_press"); +} +pub export fn mob_send_double_tap(handle: c_int) callconv(.c) void { + sendEvent(handle, "double_tap"); +} +pub export fn mob_send_swipe_left(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_left"); +} +pub export fn mob_send_swipe_right(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_right"); +} +pub export fn mob_send_swipe_up(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_up"); +} +pub export fn mob_send_swipe_down(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_down"); +} + +pub export fn mob_send_swipe_with_direction(handle: c_int, direction: [*:0]const u8) callconv(.c) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "swipe"), + erts.enif_make_copy(env, snap.tag), + erts.enif_make_atom(env, direction), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Throttle infrastructure (Batch 5 Tier 1) ──────────────────────────── +// Per-handle throttle + delta-threshold gating, mirroring iOS. Phase +// boundaries (began/ended) bypass the throttle so the BEAM always sees +// the start + stop of a gesture even when intermediate samples are +// dropped. + +pub export fn mob_set_throttle_config( + handle: c_int, + throttle_ms: c_int, + debounce_ms: c_int, + delta_threshold: f64, + leading: c_int, + trailing: c_int, +) callconv(.c) void { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_handle_next) return; + const h = &tap_handles[@intCast(handle)]; + if (h.tag_env == null) return; + h.throttle_ms = throttle_ms; + h.debounce_ms = debounce_ms; + h.delta_threshold = delta_threshold; + h.leading = leading; + h.trailing = trailing; +} + +/// Returns true if this sample should emit (and updates last_emit_ns + +/// last_x/y + seq under the mutex). `default_throttle_ms` and +/// `default_delta` are the gesture-specific defaults applied when the +/// per-handle config left those fields at 0. +fn throttleCheck(handle: c_int, x: f64, y: f64, default_throttle_ms: i32, default_delta: f64) bool { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_handle_next) return false; + const h = &tap_handles[@intCast(handle)]; + if (h.tag_env == null) return false; + + const throttle_ms: i32 = if (h.throttle_ms != 0) h.throttle_ms else default_throttle_ms; + const delta_threshold: f64 = if (h.delta_threshold > 0) h.delta_threshold else default_delta; + + const now_ns = jni.nowNs(); + const dx = x - h.last_x; + const dy = y - h.last_y; + const dist = @abs(dx) + @abs(dy); + + if (h.last_emit_ns > 0 and throttle_ms > 0) { + const elapsed_ms = @divTrunc(now_ns - h.last_emit_ns, 1_000_000); + if (elapsed_ms < throttle_ms) return false; + } + if (h.last_emit_ns > 0 and dist < delta_threshold) return false; + + h.last_emit_ns = now_ns; + h.last_x = x; + h.last_y = y; + h.seq +%= 1; // wrap on overflow; matches C's `++` on unsigned long long + return true; +} + +inline fn isPhaseBoundary(phase: [*:0]const u8) bool { + const span = std.mem.span(phase); + return std.mem.eql(u8, span, "began") or std.mem.eql(u8, span, "ended"); +} + +/// Build the scroll/drag payload map. Caller owns `env`. +fn buildScrollMap( + env: ?*erts.ErlNifEnv, + x: f64, + y: f64, + dx: f64, + dy: f64, + vx: f64, + vy: f64, + phase: [*:0]const u8, + ts_ms: i64, + seq: u64, +) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "dx"), + erts.enif_make_atom(env, "dy"), + erts.enif_make_atom(env, "velocity_x"), + erts.enif_make_atom(env, "velocity_y"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, dx), + erts.enif_make_double(env, dy), + erts.enif_make_double(env, vx), + erts.enif_make_double(env, vy), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, seq), + }; + return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error"); +} + +pub export fn mob_send_scroll( + handle: c_int, + x: f64, + y: f64, + dx: f64, + dy: f64, + vx: f64, + vy: f64, + phase: [*:0]const u8, +) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, x, y, 33, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const payload = buildScrollMap(env, x, y, dx, dy, vx, vy, phase, ts_ms, snap.seq); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "scroll"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_drag( + handle: c_int, + x: f64, + y: f64, + dx: f64, + dy: f64, + phase: [*:0]const u8, +) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, x, y, 16, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "dx"), + erts.enif_make_atom(env, "dy"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, dx), + erts.enif_make_double(env, dy), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "drag"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_pinch(handle: c_int, scale: f64, velocity: f64, phase: [*:0]const u8) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, scale, 0, 16, 0.01)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "scale"), + erts.enif_make_atom(env, "velocity"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, scale), + erts.enif_make_double(env, velocity), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "pinch"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_rotate(handle: c_int, degrees: f64, velocity: f64, phase: [*:0]const u8) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, degrees, 0, 16, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "degrees"), + erts.enif_make_atom(env, "velocity"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, degrees), + erts.enif_make_double(env, velocity), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "rotate"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_pointer_move(handle: c_int, x: f64, y: f64) callconv(.c) void { + if (!throttleCheck(handle, x, y, 33, 4.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "pointer_move"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Tier 2: semantic single-fire scroll events ────────────────────────── + +pub export fn mob_send_scroll_began(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_began"); +} +pub export fn mob_send_scroll_ended(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_ended"); +} +pub export fn mob_send_scroll_settled(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_settled"); +} +pub export fn mob_send_top_reached(handle: c_int) callconv(.c) void { + sendEvent(handle, "top_reached"); +} +pub export fn mob_send_scrolled_past(handle: c_int) callconv(.c) void { + sendEvent(handle, "scrolled_past"); +} + +// ── Component event sender ────────────────────────────────────────────── + +pub export fn mob_send_component_event( + handle: c_int, + event: [*:0]const u8, + payload_json: [*:0]const u8, +) callconv(.c) void { + if (handle < 0 or handle >= @as(c_int, @intCast(MAX_COMPONENT_HANDLES))) return; + erts.enif_mutex_lock(component_mutex); + const slot = &component_handles[@intCast(handle)]; + if (slot.active == 0) { + erts.enif_mutex_unlock(component_mutex); + return; + } + const pid_copy = slot.pid; + erts.enif_mutex_unlock(component_mutex); + + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "component_event"), + erts.enif_make_string(env, event, erts.ERL_NIF_LATIN1), + erts.enif_make_string(env, payload_json, erts.ERL_NIF_LATIN1), + }); + var pid = pid_copy; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Back gesture ──────────────────────────────────────────────────────── + +/// Called from beam_jni.c's nativeHandleBack JNI stub when the Android +/// back gesture fires. Looks up the :mob_screen registered process and +/// sends {:mob, :back}. Mob.Screen handles popping the nav stack or +/// exiting the app at root. +pub export fn mob_handle_back() callconv(.c) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (erts.enif_whereis_pid(env, erts.enif_make_atom(env, "mob_screen"), &pid) != 0) { + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "mob"), + erts.enif_make_atom(env, "back"), + }); + _ = erts.enif_send(null, &pid, env, msg); + } +} + +// ── NIFs that touch the tap registry / g_transition / Bridge.set_root ─── +// (Ported alongside the senders so all consumers of these statics are +// co-located in Zig.) + +// nif_set_root/1 — pass JSON node tree to Compose. Snapshots the current +// `g_transition` (set by nif_set_transition before this call) and resets +// it to "none" so the next render starts from a clean default. +export fn nif_set_root( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0 and + erts.enif_inspect_iolist_as_binary(env, argv[0], &bin) == 0) + { + return erts.badarg(env); + } + + // Null-terminate for NewStringUTF. + const json_ptr: ?*anyopaque = jni.malloc(bin.size + 1) orelse + return erts.atom(env, "error"); + defer jni.free(json_ptr); + const json_buf: [*]u8 = @ptrCast(json_ptr); + @memcpy(json_buf[0..bin.size], bin.data[0..bin.size]); + json_buf[bin.size] = 0; + const json_cstr: [*:0]const u8 = @ptrCast(json_buf); + + // Snapshot transition under the mutex; reset to "none" for next call. + var transition: [16]u8 = @splat(0); + erts.enif_mutex_lock(tap_mutex); + @memcpy(&transition, &g_transition); + @memset(&g_transition, 0); + g_transition[0] = 'n'; + g_transition[1] = 'o'; + g_transition[2] = 'n'; + g_transition[3] = 'e'; + erts.enif_mutex_unlock(tap_mutex); + const transition_cstr: [*:0]const u8 = @ptrCast(&transition); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jjson = jni.newStringUTF(jenv, json_cstr); + const jtransition = jni.newStringUTF(jenv, transition_cstr); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_root, jjson, jtransition); + jni.deleteLocalRef(jenv, jjson); + jni.deleteLocalRef(jenv, jtransition); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_register_tap/1 — accepts a pid (tag = :ok) or {pid, tag} (any term +// as the tag). Returns the integer handle. +export fn nif_register_tap( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + var tag_term: erts.ERL_NIF_TERM = undefined; + + if (erts.enif_get_local_pid(env, argv[0], &pid) != 0) { + tag_term = erts.enif_make_atom(env, "ok"); + } else { + var arity: c_int = 0; + var elems: [*]const erts.ERL_NIF_TERM = undefined; + if (erts.enif_get_tuple(env, argv[0], &arity, &elems) == 0 or arity != 2) { + return erts.badarg(env); + } + if (erts.enif_get_local_pid(env, elems[0], &pid) == 0) return erts.badarg(env); + tag_term = elems[1]; + } + + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (tap_handle_next >= @as(c_int, @intCast(MAX_TAP_HANDLES))) return erts.badarg(env); + + const handle: c_int = tap_handle_next; + tap_handle_next += 1; + const slot = &tap_handles[@intCast(handle)]; + slot.pid = pid; + slot.tag_env = erts.enif_alloc_env() orelse return erts.atom(env, "error"); + slot.tag = erts.enif_make_copy(slot.tag_env, tag_term); + return erts.enif_make_int(env, handle); +} + +// nif_clear_taps/0 — cleared at the start of every render. Frees each +// slot's tag_env (which owns the persistent tag term) and zeroes the +// throttle state so reuse across renders doesn't leak stale config. +export fn nif_clear_taps( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + var i: usize = 0; + while (i < @as(usize, @intCast(tap_handle_next))) : (i += 1) { + const h = &tap_handles[i]; + if (h.tag_env != null) { + erts.enif_free_env(h.tag_env); + h.tag_env = null; + } + // Reset throttle state — slots get reused across renders. + h.throttle_ms = 0; + h.debounce_ms = 0; + h.delta_threshold = 0; + h.leading = 1; + h.trailing = 1; + h.last_emit_ns = 0; + h.last_x = 0; + h.last_y = 0; + h.seq = 0; + } + tap_handle_next = 0; + return erts.ok(env); +} + +// nif_set_transition/1 — store the transition type atom (push/pop/reset/ +// none) to be picked up by the next set_root call. Must be called before +// set_root for the transition to take effect on that render. +export fn nif_set_transition( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (erts.enif_get_atom(env, argv[0], &g_transition, g_transition.len, erts.ERL_NIF_LATIN1) == 0) { + return erts.badarg(env); + } + return erts.ok(env); +} + +// nif_register_component/1 — allocate a persistent component handle for +// a Native View pid. Linear scan through MAX_COMPONENT_HANDLES slots; +// fails when all are in use. +export fn nif_register_component( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + if (erts.enif_get_local_pid(env, argv[0], &pid) == 0) return erts.badarg(env); + + erts.enif_mutex_lock(component_mutex); + defer erts.enif_mutex_unlock(component_mutex); + var i: usize = 0; + while (i < MAX_COMPONENT_HANDLES) : (i += 1) { + if (component_handles[i].active == 0) { + component_handles[i].pid = pid; + component_handles[i].active = 1; + return erts.enif_make_int(env, @intCast(i)); + } + } + return erts.badarg(env); +} + +// nif_deregister_component/1 — release a component handle. Slot becomes +// available for the next register call. +export fn nif_deregister_component( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var handle: c_int = 0; + if (erts.enif_get_int(env, argv[0], &handle) == 0 or + handle < 0 or + handle >= @as(c_int, @intCast(MAX_COMPONENT_HANDLES))) + { + return erts.badarg(env); + } + erts.enif_mutex_lock(component_mutex); + component_handles[@intCast(handle)].active = 0; + erts.enif_mutex_unlock(component_mutex); + return erts.ok(env); +} diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig index b41afcb3..f24f7f27 100644 --- a/android/jni/mob_zig.zig +++ b/android/jni/mob_zig.zig @@ -71,6 +71,20 @@ pub extern fn closedir(dirp: *DIR) c_int; pub extern fn nanosleep(req: *const Timespec, rem: ?*Timespec) c_int; pub extern fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) c_int; +/// POSIX clock identifiers. We only use CLOCK_MONOTONIC for throttle +/// timestamps in the gesture/scroll/drag/pinch sender path — it ticks +/// forward at a constant rate regardless of wall-clock NTP adjustments. +pub const CLOCK_MONOTONIC: c_int = 1; +pub extern fn clock_gettime(clk_id: c_int, tp: *Timespec) c_int; + +/// Monotonic nanoseconds since boot. Wrapper that hides the timespec +/// dance. Used by the throttle path in the senders. +pub fn nowNs() i64 { + var ts: Timespec = .{ .tv_sec = 0, .tv_nsec = 0 }; + _ = clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1_000_000_000 + ts.tv_nsec; +} + // libc allocator. We use `std.heap.c_allocator` in only one spot (test // harness NIFs that copy a binary into a NUL-terminated buffer for // NewStringUTF), and Zig 0.17 refuses to compile `std.heap.c_allocator` From 4dfdc2f65b3e37f11c9eb370426f88ade176e4d3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 14:05:17 -0600 Subject: [PATCH 028/254] build_system_migration: Phase 6b iter 3c logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the senders + handle registries port — the concurrency- heavy core (25 senders, two mutex-guarded registries, the throttle infrastructure for the Batch-5 high-frequency events). After this iter mob_nif.c is at 1500 lines (41% reduction from iter-3a start) and 56% of the native code is Zig. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 54 +++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index 1ceba3e0..134e7169 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1230,11 +1230,55 @@ something useful even if the total project pauses. clean. mob_new template needs no change this iter — mob_nif.zig source spec was wired in iter 3a and new NIFs are internal to that file. - - iter 3c (planned): the concurrency-heavy core — cached - MobBridge method ID struct, tap/component handle registries, - per-handle throttle state, all `mob_send_*` event senders. - Must coordinate with beam_jni.c which is the C-side caller - of the public sender API. + - iter 3c (senders + handle registries): the concurrency-heavy + core. mob_nif.c is 1500 lines after this iter — 41% reduction + from the 2568 it started at iter 3a, and the native code is + now 56% Zig. Moved: + + • Handle registries: `TapHandle` extern struct (with per- + handle throttle state) + `tap_handles[256]` + `tap_mutex` + + `tap_handle_next`; `ComponentHandle` + `component_handles[64]` + + `component_mutex`. `g_transition` (per-render transition + snapshot consumed by set_root). + • `mob_nif_init_state` — exported initializer that nif_load + (still in C) calls during BEAM init. Replaces the inline + `enif_mutex_create` pair that used to live in nif_load. + • 25 sender functions: `mob_send_tap`, + `mob_send_component_event`, `mob_send_change_{str,bool,float}`, + `mob_send_{focus,blur,submit,select,compose}`, the gesture + senders (long_press, double_tap, swipe_{left,right,up,down, + with_direction}), the throttled Tier-1 senders + (scroll/drag/pinch/rotate/pointer_move with seq + ts_ms + + the began/ended phase-boundary bypass), the Tier-2 single- + fire (scroll_began/_ended/_settled, top_reached, + scrolled_past), and `mob_handle_back`. + • Throttle infrastructure: `throttleCheck` (replaces C + `mob_throttle_check_a` — same throttle_ms / delta_threshold + / seq-bump semantics), `buildScrollMap`, `isPhaseBoundary`. + `snapTap` / `sendEvent` / `sendChange` are internal helpers + that lock the mutex, snapshot pid + tag + seq, then drop + the lock before `enif_send` so we never block delivery + with the mutex held. + • 6 NIFs that touch the registries: `nif_set_root`, + `nif_register_tap`, `nif_clear_taps`, `nif_set_transition`, + `nif_register_component`, `nif_deregister_component`. + + FFI extensions: + + • mob_erts.zig: enif_send, enif_self, enif_make_copy, + enif_alloc_env, enif_free_env, enif_mutex_create / _lock / + _unlock, enif_get_local_pid, enif_whereis_pid, + enif_make_int64 / _uint64, enif_get_tuple. The full + process-hop + mutex surface. + • mob_zig.zig: clock_gettime + CLOCK_MONOTONIC + nowNs() + wrapper for the throttle path's monotonic timestamps. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with 55 + exported symbols (Bridge + get_jenv + mob_nif_init_state + 25 + senders + mob_handle_back + 23 nif_*). 702/702 mob tests + + credo strict clean + clang-format clean. mob_nif.c lost ~666 + lines net. - iter 3d (planned): remaining feature NIFs — storage, WebView, alert/action_sheet/toast, native view components, background lifecycle, Mob.Device. Moves the `ErlNifFunc nif_funcs[]` From 4173176da13aa0ab0f473b7f98cbfcc733eeacce Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 14:25:28 -0600 Subject: [PATCH 029/254] =?UTF-8?q?Phase=206b=20iter=203d=20(mob=20side):?= =?UTF-8?q?=20finale=20=E2=80=94=20delete=20mob=5Fnif.c,=20all-Zig=20NIF?= =?UTF-8?q?=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finale of the multi-iter mob_nif port. mob_nif.c is gone. The entire NIF surface — including the `mob_nif_nif_init` entry point that driver_tab_android.zig references, the `nif_funcs[]` table, the `nif_load` BEAM callback, and the manually-constructed `ErlNifEntry` that replaces the C `ERL_NIF_INIT` macro — now lives in mob_nif.zig. Final stats: 0 lines of C, 2932 lines of Zig in mob_nif.zig + 281 in mob_erts.zig + 569 in mob_zig.zig + 675 in mob_beam.zig. mob_nif.c started iter 3a at 2570 lines. Moved to mob_nif.zig in this iter: * Bridge bootstrap helpers — `_mob_ui_cache_class_impl` (cache MobBridge jclass + the optional setStartupPhase / setStartupError methods), `mob_set_startup_phase`, `mob_set_startup_error`, `_mob_bridge_init_activity` (calls MobBridge.init(Activity)). All exported with C ABI so mob_beam.zig and beam_jni.c keep calling them unchanged. * 12 standalone feature NIFs: color_scheme, exit_app, safe_area, haptic, clipboard_put, clipboard_get, open_url, share_text, take_launch_notification, biometric_authenticate, audio_set_volume, notify_cancel. * 26 pid-async capability NIFs: request_permission, location_*×3, camera_*×4, photos_pick, files_pick, audio_*×3 (start_recording, stop_recording, play), motion_*×2, scanner_scan, notify_*×3 (schedule, cancel, register_push), storage_*×4 (dir, save_to_media_store, external_files_dir, save_to_photo_library Android stub). * UI surface NIFs: alert_show, action_sheet_show, toast_show, webview_*×4 (eval_js, post_message, can_go_back, go_back). * Lifecycle NIFs: background_keep_alive, background_stop. * Mob.Device surface: g_device_dispatcher_pid, g_device_dispatcher_set, mob_send_color_scheme_changed (delivered to dispatcher when set), nif_device_set_dispatcher, plus 6 stub NIFs (battery, thermal, low_power_mode, foreground, os_version, model — all return sensible defaults until Android-side ProcessLifecycleOwner + BatteryManager wiring lands). * Async result dispatchers (called from Kotlin via JNI): mob_nif_deliver_json (legacy no-op), mob_deliver_atom2/atom3, mob_deliver_location, mob_deliver_motion, mob_deliver_webview_message/blocked (with deliverWebviewBinary helper that routes to :mob_screen when jpid == 0), mob_deliver_file_result (the cancelled/result discriminator), mob_deliver_push_token, mob_deliver_notification, mob_deliver_alert_action. * Launch notification global: g_launch_notif_json + g_launch_notif_mutex + mob_set_launch_notification (the Kotlin-driven writer) + nif_take_launch_notification. * Bridge call helpers: callBridgePidStr / callBridgePidStr2 (the standard shape for the pid-async NIFs above; mirrors the C original's call_bridge_pid_str family). pidToJlong / pidFromLong cast via @bitCast — ErlNifPid is `{ c_ulong pid; }` on aarch64 so it round-trips through a jlong cleanly. * Helpers: getBinOrIolist (accept binary or iolist with a single-call orelse pattern), binToCString / freeCString (malloc + memcpy + null-terminate, for JNI NewStringUTF), jstringToBinaryTerm (the GetStringUTFChars/strlen/alloc_binary/release/delete dance). * The NIF table itself: `nif_funcs` (75 entries, dirty-job flags preserved on the four CPU-bound NIFs), `mob_nif_entry` (manually constructed ErlNifEntry matching what ERL_NIF_INIT(mob_nif, ...) used to emit — major=2, minor=18, options=1 for dirty NIFs, min_erts="erts-14.0"), and `mob_nif_nif_init` (exported with C ABI, returns &mob_nif_entry). driver_tab_android.zig already extern-declares mob_nif_nif_init so the static-NIF link path keeps working unchanged. FFI extensions: * mob_erts.zig: ErlNifEntry struct + ErlNifLoadFn/ReloadFn/ UpgradeFn/UnloadFn typedefs + ERL_NIF_MAJOR/MINOR/MIN_ERTS/ VM_VARIANT constants + ERL_NIF_DIRTY_JOB_CPU/IO_BOUND flags + SIZEOF_ErlNifResourceTypeInit (the ABI-compat gate). * mob_zig.zig: strlen + strdup extern decls (only places we need them are the launch-notif strdup-and-store pattern and the deliver_* path's UTF-8 length computation). Verified: standalone `zig build-obj -target aarch64-linux-android.24` produces a clean mob_nif.o with 124 exported symbols. All beam_jni.c-side references resolve (`mob_send_*`, `mob_deliver_*`, `mob_handle_back`, `mob_set_launch_notification`, `mob_init_bridge`, `mob_ui_cache_class`, `mob_start_beam`, `mob_send_color_scheme_changed`). The driver_tab's `mob_nif_nif_init` symbol resolves to the Zig export. 702/702 mob tests + credo strict clean. Full Android end-to-end smoke deploy still pending — bundles best as a separate verification commit so it's clear what runtime test exercised the all-Zig finale. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_erts.zig | 48 ++ android/jni/mob_nif.c | 1500 ------------------------------------- android/jni/mob_nif.zig | 1518 +++++++++++++++++++++++++++++++++++++- android/jni/mob_zig.zig | 2 + 4 files changed, 1565 insertions(+), 1503 deletions(-) delete mode 100644 android/jni/mob_nif.c diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index 540d0bf1..8300fa20 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -64,6 +64,54 @@ pub const ErlNifFunc = extern struct { flags: c_uint, }; +/// NIF dirty-job flags. Match the `ErlNifDirtyTaskFlags` enum in erl_nif.h — +/// these are the `flags` field values for ErlNifFunc entries that should +/// dispatch on a dirty scheduler. Plain CPU-bound work on the BEAM thread +/// (JSON parse, tree walks) uses CPU_BOUND; long-blocking I/O uses IO_BOUND. +pub const ERL_NIF_DIRTY_JOB_CPU_BOUND: c_uint = 1; +pub const ERL_NIF_DIRTY_JOB_IO_BOUND: c_uint = 2; + +/// `ErlNifEntry` — the top-level NIF library descriptor. Returned by the +/// `_nif_init` symbol that the `ERL_NIF_INIT` macro generates in C. +/// Iter 3d builds this struct manually in Zig (instead of via the C macro) +/// so the entire NIF surface — table, load callback, and entry returned to +/// the BEAM — lives in mob_nif.zig. +/// +/// Major/minor + min_erts must match the headers the BEAM was built with. +/// We hard-code 2/18 + "erts-14.0" to match the bundled OTP 29 headers; if +/// you bump the OTP runtime, also bump these. `options = 1` allows dirty +/// NIFs (matches what the ERL_NIF_INIT macro emits today). +pub const ERL_NIF_MAJOR_VERSION: c_int = 2; +pub const ERL_NIF_MINOR_VERSION: c_int = 18; +pub const ERL_NIF_MIN_ERTS_VERSION: [*:0]const u8 = "erts-14.0"; +pub const ERL_NIF_VM_VARIANT: [*:0]const u8 = "beam.vanilla"; + +/// ErlNifResourceTypeInit — declared opaque (we never construct one; only +/// its size is read from the entry to gate ABI compatibility). Size on +/// aarch64-linux: 5 pointers = 40 bytes (dtor/stop/down/dyncall + int). +pub const SIZEOF_ErlNifResourceTypeInit: usize = 40; + +pub const ErlNifLoadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifReloadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifUpgradeFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, old_priv: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifUnloadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: ?*anyopaque) callconv(.c) void; + +pub const ErlNifEntry = extern struct { + major: c_int, + minor: c_int, + name: [*:0]const u8, + num_of_funcs: c_int, + funcs: [*]const ErlNifFunc, + load: ErlNifLoadFn, + reload: ErlNifReloadFn, + upgrade: ErlNifUpgradeFn, + unload: ErlNifUnloadFn, + vm_variant: [*:0]const u8, + options: c_uint, + sizeof_ErlNifResourceTypeInit: usize, + min_erts: [*:0]const u8, +}; + // ── Term constructors ───────────────────────────────────────────────────── pub extern fn enif_make_atom(env: ?*ErlNifEnv, name: [*:0]const u8) ERL_NIF_TERM; diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c deleted file mode 100644 index 3915516a..00000000 --- a/android/jni/mob_nif.c +++ /dev/null @@ -1,1500 +0,0 @@ -// mob_nif.c — Mob UI NIF for Android (Jetpack Compose backend). -// -// NIF functions: -// platform/0 — returns :android -// log/1, log/2 — Android logcat -// set_root/1 — pass JSON node tree to Compose -// register_tap/1 — register ErlNifPid, get integer handle back -// clear_taps/0 — clear tap registry before each render - -#include "erl_nif.h" -#include "mob_beam.h" -#include -#include -#include -#include -#include -#include - -#define LOG_TAG "MobNIF" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -// ── NIFs defined in mob_nif.zig ─────────────────────────────────────────────── -// The Zig file exports these with the standard NIF C-ABI signature; the -// static nif_funcs[] table below references them by symbol name. As later -// sub-iters port more NIFs, they get added to this extern block — eventually -// (iter 3d) the whole table moves to Zig and these externs go away. -// iter 3a: -extern ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -// iter 3b — test harness: -extern ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); - -// ── Cached JNI method IDs (definition moved to mob_nif.zig in iter 3b) ────── -// The matching extern struct here is the C view of the same memory the Zig -// side defines and exports. Field order is load-bearing — drift here will -// silently mis-resolve method IDs at runtime. When a Bridge field is added -// or removed, BOTH this declaration AND the BridgeMethods extern struct in -// mob_nif.zig must change together. -struct BridgeMethods { - jclass cls; - jmethodID set_root; - jmethodID move_to_back; - jmethodID get_safe_area; - jmethodID get_color_scheme; - jmethodID haptic; - jmethodID clipboard_put; - jmethodID clipboard_get; - jmethodID share_text; - jmethodID open_url; - jmethodID request_permission; - jmethodID biometric_authenticate; - jmethodID location_get_once; - jmethodID location_start; - jmethodID location_stop; - jmethodID camera_capture_photo; - jmethodID camera_capture_video; - jmethodID camera_start_preview; - jmethodID camera_stop_preview; - jmethodID alert_show; - jmethodID action_sheet_show; - jmethodID toast_show; - jmethodID webview_eval_js; - jmethodID webview_post_message; - jmethodID webview_can_go_back; - jmethodID webview_go_back; - jmethodID photos_pick; - jmethodID files_pick; - jmethodID audio_start_recording; - jmethodID audio_stop_recording; - jmethodID audio_play; - jmethodID audio_stop_playback; - jmethodID audio_set_volume; - jmethodID motion_start; - jmethodID motion_stop; - jmethodID scanner_scan; - jmethodID notify_schedule; - jmethodID notify_cancel; - jmethodID notify_register_push; - jmethodID take_launch_notification; - jmethodID storage_dir; - jmethodID storage_save_to_media_store; - jmethodID storage_external_files_dir; - jmethodID background_keep_alive; - jmethodID background_stop; - // Cached before nif_load (used during BEAM startup before NIFs are loaded) - jmethodID set_startup_phase; - jmethodID set_startup_error; - // ── Test harness ────────────────────────────────────────────────────────── - jmethodID ui_tree; - jmethodID ui_view_tree; - jmethodID screen_info; - jmethodID tap_xy; - jmethodID tap_by_label; - jmethodID type_text; - jmethodID delete_backward; - jmethodID clear_text; - jmethodID long_press_xy; - jmethodID swipe_xy; -}; -extern struct BridgeMethods Bridge; - -// JNI thread-attach helper (definition moved to mob_nif.zig). The senders -// + feature NIFs still in this file call it like before; the function -// itself is now exported with C ABI from Zig. -extern JNIEnv *get_jenv(int *attached); - -// ── Senders + handle registries + 6 NIFs moved to mob_nif.zig (iter 3c) ───── -// The full mob_send_* family (tap, change_str/bool/float, focus/blur/submit/ -// select/compose, gesture senders, throttled scroll/drag/pinch/rotate/ -// pointer_move, scroll-began/ended/settled, swipe_with_direction, back, -// component_event) and the TapHandle / ComponentHandle registries with their -// mutexes are now in Zig. Six NIFs that touched those statics moved with -// them: set_root, register_tap, clear_taps, set_transition, register_component, -// deregister_component. -// -// nif_load (below) calls mob_nif_init_state() — also exported from -// mob_nif.zig — to create the mutexes during BEAM init. -extern int mob_nif_init_state(void); - -// iter 3c — registry NIFs: -extern ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); -extern ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); - -// ── JNI helpers (get_jenv moved to mob_nif.zig in iter 3b) ────────────────── - -// ── Cache MobBridge class (called from mob_beam.c) ─────────────────────────── - -void _mob_ui_cache_class_impl(JNIEnv *jenv, const char *bridge_class) { - LOGI("mob_ui_cache_class: looking up %s", bridge_class); - jclass cls = (*jenv)->FindClass(jenv, bridge_class); - if (!cls) { - LOGE("mob_ui_cache_class: %s not found", bridge_class); - return; - } - Bridge.cls = (*jenv)->NewGlobalRef(jenv, cls); - (*jenv)->DeleteLocalRef(jenv, cls); - // Cache startup status methods now — they're needed before nif_load runs. - // These are optional (older MobBridge versions may not have them); clear - // any pending exception rather than aborting. - Bridge.set_startup_phase = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_phase) - (*jenv)->ExceptionClear(jenv); - Bridge.set_startup_error = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_error) - (*jenv)->ExceptionClear(jenv); - LOGI("mob_ui_cache_class: %s cached OK", bridge_class); -} - -void mob_set_startup_phase(const char *phase) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_phase) - return; - int att; - JNIEnv *env = get_jenv(&att); - jstring js = (*env)->NewStringUTF(env, phase); - (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_phase, js); - (*env)->DeleteLocalRef(env, js); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - LOGI("startup: %s", phase); -} - -void mob_set_startup_error(const char *error) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_error) - return; - int att; - JNIEnv *env = get_jenv(&att); - jstring js = (*env)->NewStringUTF(env, error); - (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_error, js); - (*env)->DeleteLocalRef(env, js); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - LOGE("startup ERROR: %s", error); -} - -// ── Initialize bridge with Activity (called from mob_beam.c) ───────────────── - -void _mob_bridge_init_activity(JNIEnv *env, jobject activity) { - if (!Bridge.cls) { - LOGE("_mob_bridge_init_activity: Bridge.cls not cached"); - return; - } - jmethodID init = - (*env)->GetStaticMethodID(env, Bridge.cls, "init", "(Landroid/app/Activity;)V"); - (*env)->CallStaticVoidMethod(env, Bridge.cls, init, activity); - LOGI("_mob_bridge_init_activity: MobBridge.init called"); -} - -// ── NIFs moved to mob_nif.zig (Phase 6b iter 3a) ───────────────────────────── -// `nif_platform/0`, `nif_log/1`, `nif_log/2` (+ the atom_to_android_priority -// helper that only `nif_log/2` used) are now defined in mob_nif.zig. The -// nif_funcs[] table below references them via the extern declarations near -// the top of this file. Behaviour is byte-for-byte equivalent — same atom -// names, same priority mapping, same 4 KB truncation, same fallback to -// `enif_get_string` for charlists. - -// ── NIF: color_scheme/0 ────────────────────────────────────────────────────── -// Returns :light or :dark based on the Activity's current Configuration.uiMode. -// Returns :light if MobBridge.getColorScheme() isn't compiled into the app -// (older projects). - -static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.get_color_scheme) - return enif_make_atom(env, "light"); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring result = - (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.get_color_scheme); - ERL_NIF_TERM atom = enif_make_atom(env, "light"); - if (result) { - const char *str = (*jenv)->GetStringUTFChars(jenv, result, NULL); - if (str) { - if (strcmp(str, "dark") == 0) - atom = enif_make_atom(env, "dark"); - (*jenv)->ReleaseStringUTFChars(jenv, result, str); - } - (*jenv)->DeleteLocalRef(jenv, result); - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return atom; -} - -// ── NIF: set_root/1 ────────────────────────────────────────────────────────── -// Accepts a JSON binary and passes it to MobBridge.setRootJson(String) on the -// Kotlin side. Compose state update is thread-safe — no main-thread hop needed. - -// nif_set_root / nif_register_tap / nif_clear_taps / nif_set_transition -// moved to mob_nif.zig (iter 3c). - -// ── NIF: exit_app/0 ────────────────────────────────────────────────────────── -// Backgrounds the app via MobBridge.moveToBack() → activity.moveTaskToBack(true). -// Called by Mob.Screen when the back gesture fires at the root of the nav stack. - -static ERL_NIF_TERM nif_exit_app(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.move_to_back); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: safe_area/0 ───────────────────────────────────────────────────────── -// Returns {Top, Right, Bottom, Left} in dp via MobBridge.getSafeArea(). - -static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - jfloatArray arr = - (jfloatArray)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.get_safe_area); - float vals[4] = {0.0f, 0.0f, 0.0f, 0.0f}; - if (arr) { - (*jenv)->GetFloatArrayRegion(jenv, arr, 0, 4, vals); - (*jenv)->DeleteLocalRef(jenv, arr); - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_tuple4( - env, enif_make_double(env, (double)vals[0]), enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), enif_make_double(env, (double)vals[3])); -} - -// ── NIF: haptic/1 ───────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_haptic(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char type[32] = {0}; - enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtype = (*jenv)->NewStringUTF(jenv, type); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.haptic, jtype); - (*jenv)->DeleteLocalRef(jenv, jtype); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *text = (char *)malloc(bin.size + 1); - if (!text) - return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.clipboard_put, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: clipboard_get/0 ────────────────────────────────────────────────────── -// Returns {:ok, Binary} or :empty. - -static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - jstring result = - (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.clipboard_get); - - ERL_NIF_TERM ret; - if (result) { - const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_tuple2(env, enif_make_atom(env, "ok"), enif_make_binary(env, &bin)); - } else { - ret = enif_make_atom(env, "empty"); - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -// ── NIF: open_url/1 ─────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_open_url(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *url = (char *)malloc(bin.size + 1); - if (!url) - return enif_make_atom(env, "error"); - memcpy(url, bin.data, bin.size); - url[bin.size] = 0; - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jurl = (*jenv)->NewStringUTF(jenv, url); - free(url); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.open_url, jurl); - (*jenv)->DeleteLocalRef(jenv, jurl); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: share_text/1 ───────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_share_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *text = (char *)malloc(bin.size + 1); - if (!text) - return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.share_text, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Device capability NIFs (Android JNI bridge) -// Each calls a static method on MobBridge with the PID encoded as a long so -// Kotlin can call mob_nif_deliver_event() with the result. -// ════════════════════════════════════════════════════════════════════════════ - -// Launch notification global (written by MobBridge.setLaunchNotification, read once) -static char *g_launch_notif_json = NULL; -static ErlNifMutex *g_launch_notif_mutex = NULL; - -// Called from MobBridge.setLaunchNotification(json) -void mob_set_launch_notification(const char *json) { - if (!g_launch_notif_mutex) - return; - enif_mutex_lock(g_launch_notif_mutex); - free(g_launch_notif_json); - g_launch_notif_json = json ? strdup(json) : NULL; - enif_mutex_unlock(g_launch_notif_mutex); -} - -static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc, - const ERL_NIF_TERM argv[]) { - if (!g_launch_notif_mutex) - return enif_make_atom(env, "none"); - enif_mutex_lock(g_launch_notif_mutex); - char *json = g_launch_notif_json; - g_launch_notif_json = NULL; - enif_mutex_unlock(g_launch_notif_mutex); - if (!json) - return enif_make_atom(env, "none"); - ErlNifBinary bin; - enif_alloc_binary(strlen(json), &bin); - memcpy(bin.data, json, strlen(json)); - free(json); - return enif_make_binary(env, &bin); -} - -// Generic helper: call Kotlin static method(pid_long, string_arg) -static ERL_NIF_TERM call_bridge_pid_str(ErlNifEnv *env, jmethodID method, ErlNifPid pid, - const char *arg) { - int att; - JNIEnv *jenv = get_jenv(&att); - jlong jpid; - memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - jstring jarg = arg ? (*jenv)->NewStringUTF(jenv, arg) : NULL; - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, jarg); - if (jarg) - (*jenv)->DeleteLocalRef(jenv, jarg); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM call_bridge_pid_str2(ErlNifEnv *env, jmethodID method, ErlNifPid pid, - const char *a1, const char *a2) { - int att; - JNIEnv *jenv = get_jenv(&att); - jlong jpid; - memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - jstring j1 = a1 ? (*jenv)->NewStringUTF(jenv, a1) : NULL; - jstring j2 = a2 ? (*jenv)->NewStringUTF(jenv, a2) : NULL; - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, j1, j2); - if (j1) - (*jenv)->DeleteLocalRef(jenv, j1); - if (j2) - (*jenv)->DeleteLocalRef(jenv, j2); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// mob_nif_deliver_event — called from Kotlin with a JSON string result. -// Decodes the JSON and sends the appropriate BEAM message to the pid stored in it. -// JSON format: {"pid": , "event": [...erlang term json...]} -// We use a simpler approach: Kotlin encodes the event as a JSON array describing the term. -// Actually, simplest approach: Kotlin constructs a binary JSON string, and we route -// to pre-stored PIDs in a simple table. But since we pass the PID as a long to Kotlin, -// Kotlin passes it back to us and we reconstruct the ErlNifPid. -// -// mob_nif_deliver_json(pid_long, json_cstr) — send pre-formed JSON event to pid -// This is declared in mob_beam.h for Kotlin to call via JNI. -void mob_nif_deliver_json(jlong pid_long, const char *json_str) { - // We don't send JSON to the BEAM — we need to build proper Erlang terms. - // Instead, we use a set of typed delivery functions called from Kotlin. - // See mob_beam.h for the full set. -} - -// Typed event delivery functions called from Kotlin/JNI -// These are declared in mob_beam.h and implemented here. - -static ErlNifPid pid_from_long(jlong jpid) { - ErlNifPid pid; - memset(&pid, 0, sizeof(pid)); - memcpy(&pid, &jpid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - return pid; -} - -void mob_deliver_atom2(jlong jpid, const char *a1, const char *a2) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, a1), enif_make_atom(e, a2)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_atom3(jlong jpid, const char *a1, const char *a2, const char *a3) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = - enif_make_tuple3(e, enif_make_atom(e, a1), enif_make_atom(e, a2), enif_make_atom(e, a3)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_location(jlong jpid, double lat, double lon, double acc, double alt) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM keys[4] = {enif_make_atom(e, "lat"), enif_make_atom(e, "lon"), - enif_make_atom(e, "accuracy"), enif_make_atom(e, "altitude")}; - ERL_NIF_TERM vals[4] = {enif_make_double(e, lat), enif_make_double(e, lon), - enif_make_double(e, acc), enif_make_double(e, alt)}; - ERL_NIF_TERM map; - enif_make_map_from_arrays(e, keys, vals, 4, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "location"), map); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_motion(jlong jpid, double ax, double ay, double az, double gx, double gy, - double gz, long long ts) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), enif_make_double(e, ay), - enif_make_double(e, az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), enif_make_double(e, gy), - enif_make_double(e, gz)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), - enif_make_atom(e, "timestamp")}; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; - ERL_NIF_TERM map; - enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "motion"), map); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// Deliver a {:webview, tag, binary} message. When jpid==0, looks up :mob_screen. -static void deliver_webview_binary(jlong jpid, const char *tag, const char *utf8) { - ErlNifEnv *e = enif_alloc_env(); - ErlNifPid pid; - if (jpid != 0) { - pid = pid_from_long(jpid); - } else if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); - return; - } - size_t len = strlen(utf8); - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "webview"), enif_make_atom(e, tag), - enif_make_binary(e, &bin)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_webview_message(jlong jpid, const char *json) { - deliver_webview_binary(jpid, "message", json); -} - -void mob_deliver_webview_blocked(jlong jpid, const char *url) { - deliver_webview_binary(jpid, "blocked", url); -} - -void mob_deliver_file_result( - jlong jpid, const char *event, // "camera","photos","files","audio","scan" - const char *sub, // "photo","video","picked","recorded","result","cancelled" - const char *json_items) { // JSON array of item maps, or NULL for cancelled - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (!json_items || strcmp(json_items, "cancelled") == 0) { - msg = enif_make_tuple2(e, enif_make_atom(e, event), enif_make_atom(e, "cancelled")); - } else { - // Parse JSON array of maps and build Erlang list - // Simple approach: pass the raw JSON binary as a string; the BEAM can decode it if needed. - // Better: build proper terms here. - // For now, pass as binary; Elixir side can use :json.decode. - // But we want typed data, so let's build a simple list of maps. - // We'll use a JSON-like binary approach: send the raw JSON and let the BEAM decode it. - ErlNifBinary jb; - size_t jlen = strlen(json_items); - enif_alloc_binary(jlen, &jb); - memcpy(jb.data, json_items, jlen); - // Build: {event_atom, sub_atom, json_binary} - // The Elixir Mob.Screen will need to decode it. Actually, let's send the JSON - // and have Mob.Screen decode it — but screen doesn't do that for file results. - // Better: send as a tagged binary that Elixir wrappers decode. - // We'll send {:mob_file_result, event, sub, json_binary} and add a handler. - ErlNifBinary eb; - size_t el = strlen(event); - enif_alloc_binary(el, &eb); - memcpy(eb.data, event, el); - ErlNifBinary sb; - size_t sl = strlen(sub); - enif_alloc_binary(sl, &sb); - memcpy(sb.data, sub, sl); - msg = enif_make_tuple4(e, enif_make_atom(e, "mob_file_result"), enif_make_binary(e, &eb), - enif_make_binary(e, &sb), enif_make_binary(e, &jb)); - } - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_push_token(jlong jpid, const char *token) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ErlNifBinary tb; - size_t tl = strlen(token); - enif_alloc_binary(tl, &tb); - memcpy(tb.data, token, tl); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "push_token"), - enif_make_atom(e, "android"), enif_make_binary(e, &tb)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_notification(jlong jpid, const char *json) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv *e = enif_alloc_env(); - ErlNifBinary jb; - size_t jl = strlen(json); - enif_alloc_binary(jl, &jb); - memcpy(jb.data, json, jl); - ERL_NIF_TERM msg = - enif_make_tuple2(e, enif_make_atom(e, "mob_launch_notification"), enif_make_binary(e, &jb)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// NIF implementations — thin wrappers that pass work to Kotlin - -static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char cap[32]; - enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1); - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.request_permission, pid, cap); -} - -static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv *env, int argc, - const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char reason[256] = "Authenticate"; - if (bin.size < sizeof(reason)) { - memcpy(reason, bin.data, bin.size); - reason[bin.size] = 0; - } - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.biometric_authenticate, pid, reason); -} - -static ERL_NIF_TERM nif_location_get_once(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.location_get_once, pid, "balanced"); -} - -static ERL_NIF_TERM nif_location_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char acc[16] = "balanced"; - enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.location_start, pid, acc); -} - -static ERL_NIF_TERM nif_location_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.location_stop); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char qual[16] = "high"; - enif_get_atom(env, argv[0], qual, sizeof(qual), ERL_NIF_LATIN1); - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.camera_capture_photo, pid, qual); -} - -static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int max_dur = 60; - enif_get_int(env, argv[0], &max_dur); - ErlNifPid pid; - enif_self(env, &pid); - char dur_str[16]; - snprintf(dur_str, sizeof(dur_str), "%d", max_dur); - return call_bridge_pid_str(env, Bridge.camera_capture_video, pid, dur_str); -} - -static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.camera_start_preview, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.camera_stop_preview); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_photos_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int max = 1; - enif_get_int(env, argv[0], &max); - ErlNifPid pid; - enif_self(env, &pid); - char max_str[16]; - snprintf(max_str, sizeof(max_str), "%d", max); - return call_bridge_pid_str(env, Bridge.photos_pick, pid, max_str); -} - -static ERL_NIF_TERM nif_files_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.files_pick, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.audio_start_recording, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_recording); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_audio_play(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary path_bin, opts_bin; - if (!enif_inspect_binary(env, argv[0], &path_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &opts_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) - return enif_make_badarg(env); - char *path = malloc(path_bin.size + 1); - memcpy(path, path_bin.data, path_bin.size); - path[path_bin.size] = 0; - char *opts = malloc(opts_bin.size + 1); - memcpy(opts, opts_bin.data, opts_bin.size); - opts[opts_bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str2(env, Bridge.audio_play, pid, path, opts); - free(path); - free(opts); - return result; -} - -static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_playback); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - double vol = 1.0; - enif_get_double(env, argv[0], &vol); - char vol_str[32]; - snprintf(vol_str, sizeof(vol_str), "%.6f", vol); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jvol = (*jenv)->NewStringUTF(jenv, vol_str); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_set_volume, jvol); - (*jenv)->DeleteLocalRef(jenv, jvol); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int interval_ms = 100; - enif_get_int(env, argv[1], &interval_ms); - char interval_str[16]; - snprintf(interval_str, sizeof(interval_str), "%d", interval_ms); - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.motion_start, pid, interval_str); -} - -static ERL_NIF_TERM nif_motion_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.motion_stop); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.scanner_scan, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.notify_schedule, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char nid[256] = ""; - if (bin.size < sizeof(nid)) { - memcpy(nid, bin.data, bin.size); - nid[bin.size] = 0; - } - int att; - JNIEnv *jenv = get_jenv(&att); - jstring js = (*jenv)->NewStringUTF(jenv, nid); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.notify_cancel, js); - (*jenv)->DeleteLocalRef(jenv, js); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.notify_register_push, pid, NULL); -} - -// ── Test harness NIFs moved to mob_nif.zig (Phase 6b iter 3b) ──────────────── -// nif_ui_tree, nif_ui_view_tree, nif_screen_info, nif_ui_debug, -// nif_ax_action{,_at_xy}, nif_tap, nif_tap_xy, nif_type_text, -// nif_delete_backward, nif_key_press, nif_clear_text, nif_long_press_xy, -// nif_swipe_xy + the jstring_to_bin / cstr_to_bin helpers used only by -// them now live in mob_nif.zig. The nif_funcs[] table below resolves them -// via the extern declarations near the top of this file. - -// ── Storage ─────────────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - char loc[32]; - enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jloc = (*jenv)->NewStringUTF(jenv, loc); - jstring result = - (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.storage_dir, jloc); - (*jenv)->DeleteLocalRef(jenv, jloc); - ERL_NIF_TERM ret; - if (result) { - const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_binary(env, &bin); - } else { - ret = enif_make_atom(env, "nil"); - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv *env, int argc, - const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *path = malloc(bin.size + 1); - memcpy(path, bin.data, bin.size); - path[bin.size] = 0; - char type[16] = "auto"; - enif_get_atom(env, argv[1], type, sizeof(type), ERL_NIF_LATIN1); - ErlNifPid pid; - enif_self(env, &pid); - ERL_NIF_TERM result = - call_bridge_pid_str2(env, Bridge.storage_save_to_media_store, pid, path, type); - free(path); - return result; -} - -static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv *env, int argc, - const ERL_NIF_TERM argv[]) { - char type[32] = {0}; - enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtype = (*jenv)->NewStringUTF(jenv, type); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod( - jenv, Bridge.cls, Bridge.storage_external_files_dir, jtype); - (*jenv)->DeleteLocalRef(jenv, jtype); - ERL_NIF_TERM ret; - if (result) { - const char *utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_binary(env, &bin); - } else { - ret = enif_make_atom(env, "nil"); - } - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv *env, int argc, - const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported")); -} - -// ── WebView ──────────────────────────────────────────────────────────────────── - -// ── Alert delivery (called from beam_jni.c when a dialog button is tapped) ── - -void mob_deliver_alert_action(const char *action) { - ErlNifEnv *e = enif_alloc_env(); - ErlNifPid pid; - if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); - return; - } - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "alert"), enif_make_atom(e, action)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// ── NIF: alert_show/3 ───────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary title_bin, msg_bin, btns_bin; - if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[2], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) - return enif_make_badarg(env); - - char *title = malloc(title_bin.size + 1); - memcpy(title, title_bin.data, title_bin.size); - title[title_bin.size] = '\0'; - - char *message = malloc(msg_bin.size + 1); - memcpy(message, msg_bin.data, msg_bin.size); - message[msg_bin.size] = '\0'; - - char *btns = malloc(btns_bin.size + 1); - memcpy(btns, btns_bin.data, btns_bin.size); - btns[btns_bin.size] = '\0'; - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtitle = (*jenv)->NewStringUTF(jenv, title); - jstring jmessage = (*jenv)->NewStringUTF(jenv, message); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); - free(message); - free(btns); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.alert_show, jtitle, jmessage, jbtns); - (*jenv)->DeleteLocalRef(jenv, jtitle); - (*jenv)->DeleteLocalRef(jenv, jmessage); - (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: action_sheet_show/2 ────────────────────────────────────────────── - -static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary title_bin, btns_bin; - if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) - return enif_make_badarg(env); - - char *title = malloc(title_bin.size + 1); - memcpy(title, title_bin.data, title_bin.size); - title[title_bin.size] = '\0'; - - char *btns = malloc(btns_bin.size + 1); - memcpy(btns, btns_bin.data, btns_bin.size); - btns[btns_bin.size] = '\0'; - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jtitle = (*jenv)->NewStringUTF(jenv, title); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); - free(btns); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.action_sheet_show, jtitle, jbtns); - (*jenv)->DeleteLocalRef(jenv, jtitle); - (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: toast_show/2 ──────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_toast_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary msg_bin; - char dur[8] = "short"; - if (!enif_inspect_binary(env, argv[0], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) - return enif_make_badarg(env); - enif_get_atom(env, argv[1], dur, sizeof(dur), ERL_NIF_LATIN1); - - char *msg = malloc(msg_bin.size + 1); - memcpy(msg, msg_bin.data, msg_bin.size); - msg[msg_bin.size] = '\0'; - - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jmsg = (*jenv)->NewStringUTF(jenv, msg); - jstring jdur = (*jenv)->NewStringUTF(jenv, dur); - free(msg); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.toast_show, jmsg, jdur); - (*jenv)->DeleteLocalRef(jenv, jmsg); - (*jenv)->DeleteLocalRef(jenv, jdur); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *code = malloc(bin.size + 1); - memcpy(code, bin.data, bin.size); - code[bin.size] = '\0'; - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jcode = (*jenv)->NewStringUTF(jenv, code); - free(code); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_eval_js, jcode); - (*jenv)->DeleteLocalRef(jenv, jcode); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char *json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = '\0'; - int att; - JNIEnv *jenv = get_jenv(&att); - jstring jjson = (*jenv)->NewStringUTF(jenv, json); - free(json); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_post_message, jjson); - (*jenv)->DeleteLocalRef(jenv, jjson); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - jboolean result = - (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.webview_can_go_back); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, result ? "true" : "false"); -} - -static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_go_back); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── Native view component NIFs ──────────────────────────────────────────────── -// nif_register_component / nif_deregister_component moved to mob_nif.zig -// (iter 3c) alongside the ComponentHandle registry they touch. - -// ── NIF: background_keep_alive/0, background_stop/0 ───────────────────────── - -static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_keep_alive); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_background_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - int att; - JNIEnv *jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_stop); - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── Mob.Device — lifecycle events + queries ───────────────────────────────── -// -// Android implementation is partial — only `:appearance` (color scheme -// changes from MainActivity.onConfigurationChanged) is wired today. The -// rest (lifecycle, battery, thermal) requires ProcessLifecycleObserver + -// ComponentCallbacks2 wiring. Until then the dispatcher pid is stored so -// what IS wired (color scheme) can deliver, and the query NIFs return -// reasonable defaults. - -static ErlNifPid g_device_dispatcher_pid; -static int g_device_dispatcher_set = 0; - -static void mob_device_send_atom_payload_android(const char *tag, const char *atom_name, - const char *payload_atom_str) { - if (!g_device_dispatcher_set) - return; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name), - enif_make_atom(e, payload_atom_str)); - enif_send(NULL, &g_device_dispatcher_pid, e, msg); - enif_free_env(e); -} - -// Called from beam_jni.c's Java_..._MobBridge_nativeNotifyColorScheme -// stub when MainActivity.onConfigurationChanged sees a uiMode flip. -// `scheme` must be "light" or "dark". -void mob_send_color_scheme_changed(const char *scheme) { - if (!scheme) - return; - mob_device_send_atom_payload_android("mob_device", "color_scheme_changed", scheme); -} - -static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) - return enif_make_badarg(env); - g_device_dispatcher_pid = pid; - g_device_dispatcher_set = 1; - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query BatteryManager. For now, unknown / -1. - return enif_make_tuple2(env, enif_make_atom(env, "unknown"), enif_make_int(env, -1)); -} - -static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query PowerManager.getCurrentThermalStatus() (API 29+). - return enif_make_atom(env, "nominal"); -} - -static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query PowerManager.isPowerSaveMode(). - return enif_make_atom(env, "false"); -} - -static ERL_NIF_TERM nif_device_foreground(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): track via ProcessLifecycleOwner. - return enif_make_atom(env, "true"); -} - -static ERL_NIF_TERM nif_device_os_version(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): Build.VERSION.RELEASE via JNI. - return enif_make_string(env, "", ERL_NIF_LATIN1); -} - -static ERL_NIF_TERM nif_device_model(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): Build.MODEL via JNI. - return enif_make_string(env, "Android", ERL_NIF_LATIN1); -} - -// ── NIF table & load ────────────────────────────────────────────────────────── - -// Scheduling notes — see docs/decisions/0001-dirty-nifs.md for the rationale. -// Short version: four NIFs do real CPU work on the BEAM thread (JSON parse, -// MobNode tree construction, accessibility-tree walk) and are marked -// ERL_NIF_DIRTY_JOB_CPU_BOUND so the regular scheduler isn't parked while -// they run. Everything else stays on a regular scheduler — most JNI calls -// hand off to the UI thread quickly and don't need dirty dispatch overhead. -static ErlNifFunc nif_funcs[] = { - // ── Test harness first (matches iOS nif_funcs[] ordering convention) ────── - {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ax_action", 2, nif_ax_action, 0}, - {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, - {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"screen_info", 0, nif_screen_info, 0}, - {"tap", 1, nif_tap, 0}, - {"tap_xy", 2, nif_tap_xy, 0}, - {"type_text", 1, nif_type_text, 0}, - {"delete_backward", 0, nif_delete_backward, 0}, - {"key_press", 1, nif_key_press, 0}, - {"clear_text", 0, nif_clear_text, 0}, - {"long_press_xy", 3, nif_long_press_xy, 0}, - {"swipe_xy", 4, nif_swipe_xy, 0}, - // ── Core mob functions ──────────────────────────────────────────────────── - {"platform", 0, nif_platform, 0}, - {"color_scheme", 0, nif_color_scheme, 0}, - {"log", 1, nif_log, 0}, - {"log", 2, nif_log2, 0}, - {"set_transition", 1, nif_set_transition, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"register_tap", 1, nif_register_tap, 0}, - {"clear_taps", 0, nif_clear_taps, 0}, - {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, - {"haptic", 1, nif_haptic, 0}, - {"clipboard_put", 1, nif_clipboard_put, 0}, - {"clipboard_get", 0, nif_clipboard_get, 0}, - {"share_text", 1, nif_share_text, 0}, - {"open_url", 1, nif_open_url, 0}, - {"request_permission", 1, nif_request_permission, 0}, - {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, - {"location_get_once", 0, nif_location_get_once, 0}, - {"location_start", 1, nif_location_start, 0}, - {"location_stop", 0, nif_location_stop, 0}, - {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, - {"camera_capture_video", 1, nif_camera_capture_video, 0}, - {"camera_start_preview", 1, nif_camera_start_preview, 0}, - {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, - {"photos_pick", 2, nif_photos_pick, 0}, - {"files_pick", 1, nif_files_pick, 0}, - {"audio_start_recording", 1, nif_audio_start_recording, 0}, - {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, - {"audio_play", 2, nif_audio_play, 0}, - {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, - {"audio_set_volume", 1, nif_audio_set_volume, 0}, - {"motion_start", 2, nif_motion_start, 0}, - {"motion_stop", 0, nif_motion_stop, 0}, - {"scanner_scan", 1, nif_scanner_scan, 0}, - {"notify_schedule", 1, nif_notify_schedule, 0}, - {"notify_cancel", 1, nif_notify_cancel, 0}, - {"notify_register_push", 0, nif_notify_register_push, 0}, - {"take_launch_notification", 0, nif_take_launch_notification, 0}, - {"storage_dir", 1, nif_storage_dir, 0}, - {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, - {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, - {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, - {"alert_show", 3, nif_alert_show, 0}, - {"action_sheet_show", 2, nif_action_sheet_show, 0}, - {"toast_show", 2, nif_toast_show, 0}, - {"webview_eval_js", 1, nif_webview_eval_js, 0}, - {"webview_post_message", 1, nif_webview_post_message, 0}, - {"webview_can_go_back", 0, nif_webview_can_go_back, 0}, - {"webview_go_back", 0, nif_webview_go_back, 0}, - {"register_component", 1, nif_register_component, 0}, - {"deregister_component", 1, nif_deregister_component, 0}, - {"background_keep_alive", 0, nif_background_keep_alive, 0}, - {"background_stop", 0, nif_background_stop, 0}, - // ── Mob.Device — lifecycle events + queries (Android stubs) ─────────────── - {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, - {"device_battery_state", 0, nif_device_battery_state, 0}, - {"device_thermal_state", 0, nif_device_thermal_state, 0}, - {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, - {"device_foreground", 0, nif_device_foreground, 0}, - {"device_os_version", 0, nif_device_os_version, 0}, - {"device_model", 0, nif_device_model, 0}, -}; - -static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { - LOGI("nif_load: entered, Bridge.cls=%p", (void *)Bridge.cls); - if (!Bridge.cls) { - LOGE("Bridge.cls not cached — was mob_ui_cache_class called?"); - return -1; - } - - // tap_mutex + component_mutex are defined in mob_nif.zig (iter 3c). - // mob_nif_init_state creates both. Returns 0 on success, -1 on failure - // (enif_mutex_create returned NULL) — matches our nif_load return code. - if (mob_nif_init_state() != 0) { - LOGE("nif_load: mob_nif_init_state failed (mutex create)"); - return -1; - } - - int att; - JNIEnv *jenv = get_jenv(&att); - Bridge.set_root = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setRootJson", - "(Ljava/lang/String;Ljava/lang/String;)V"); - if (!Bridge.set_root) { - LOGE("nif_load: setRootJson(String,String) not found on MobBridge"); - return -1; - } - - Bridge.move_to_back = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "moveToBack", "()V"); - if (!Bridge.move_to_back) { - LOGE("nif_load: moveToBack() not found on MobBridge"); - return -1; - } - - Bridge.get_safe_area = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "getSafeArea", "()[F"); - if (!Bridge.get_safe_area) { - LOGE("nif_load: getSafeArea() not found on MobBridge"); - return -1; - } - - // getColorScheme() is optional — apps that haven't been regenerated since - // it was added still load fine; nif_color_scheme falls back to :light. - Bridge.get_color_scheme = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "getColorScheme", "()Ljava/lang/String;"); - if (!Bridge.get_color_scheme) { - LOGI("nif_load: MobBridge.getColorScheme() not found — color_scheme/0 returns :light"); - (*jenv)->ExceptionClear(jenv); - } - - Bridge.haptic = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "haptic", "(Ljava/lang/String;)V"); - if (!Bridge.haptic) { - LOGE("nif_load: haptic(String) not found on MobBridge"); - return -1; - } - - Bridge.clipboard_put = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardPut", "(Ljava/lang/String;)V"); - if (!Bridge.clipboard_put) { - LOGE("nif_load: clipboardPut(String) not found on MobBridge"); - return -1; - } - - Bridge.clipboard_get = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardGet", "()Ljava/lang/String;"); - if (!Bridge.clipboard_get) { - LOGE("nif_load: clipboardGet() not found on MobBridge"); - return -1; - } - - Bridge.share_text = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "shareText", "(Ljava/lang/String;)V"); - if (!Bridge.share_text) { - LOGE("nif_load: shareText(String) not found on MobBridge"); - return -1; - } - - Bridge.open_url = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "openUrl", "(Ljava/lang/String;)V"); - if (!Bridge.open_url) { - LOGE("nif_load: openUrl(String) not found on MobBridge"); - return -1; - } - -#define CACHE(name, sig) \ - Bridge.name = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, #name, sig); \ - if (!Bridge.name) { \ - LOGE("nif_load: " #name " not found"); \ - return -1; \ - } - - CACHE(request_permission, "(JLjava/lang/String;)V") - CACHE(biometric_authenticate, "(JLjava/lang/String;)V") - CACHE(location_get_once, "(JLjava/lang/String;)V") - CACHE(location_start, "(JLjava/lang/String;)V") - CACHE(location_stop, "()V") - CACHE(camera_capture_photo, "(JLjava/lang/String;)V") - CACHE(camera_capture_video, "(JLjava/lang/String;)V") - CACHE(camera_start_preview, "(JLjava/lang/String;)V") - CACHE(camera_stop_preview, "()V") - CACHE(photos_pick, "(JLjava/lang/String;)V") - CACHE(files_pick, "(JLjava/lang/String;)V") - CACHE(audio_start_recording, "(JLjava/lang/String;)V") - CACHE(audio_stop_recording, "()V") - CACHE(audio_play, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(audio_stop_playback, "()V") - CACHE(audio_set_volume, "(Ljava/lang/String;)V") - CACHE(storage_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(storage_save_to_media_store, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(storage_external_files_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(alert_show, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V") - CACHE(action_sheet_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(toast_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(webview_eval_js, "(Ljava/lang/String;)V") - CACHE(webview_post_message, "(Ljava/lang/String;)V") - CACHE(webview_can_go_back, "()Z") - CACHE(webview_go_back, "()V") - CACHE(motion_start, "(JLjava/lang/String;)V") - CACHE(motion_stop, "()V") - CACHE(scanner_scan, "(JLjava/lang/String;)V") - CACHE(notify_schedule, "(JLjava/lang/String;)V") - CACHE(notify_cancel, "(Ljava/lang/String;)V") - CACHE(notify_register_push, "(JLjava/lang/String;)V") - CACHE(background_keep_alive, "()V") - CACHE(background_stop, "()V") -#undef CACHE - - g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); - if (!g_launch_notif_mutex) { - LOGE("nif_load: failed to create launch notif mutex"); - return -1; - } - -// ── Test harness method IDs (optional — clear exception if not present) ──── -#define CACHE_OPT(field, name, sig) \ - Bridge.field = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, name, sig); \ - if (!Bridge.field) { \ - (*jenv)->ExceptionClear(jenv); \ - LOGI("nif_load: %s not found (optional)", name); \ - } - - CACHE_OPT(ui_tree, "uiTree", "()Ljava/lang/String;") - CACHE_OPT(ui_view_tree, "uiViewTree", "()Ljava/lang/String;") - CACHE_OPT(screen_info, "screenInfo", "()[F") - CACHE_OPT(tap_xy, "tapXy", "(FF)Z") - CACHE_OPT(tap_by_label, "tapByLabel", "(Ljava/lang/String;)Z") - CACHE_OPT(type_text, "typeText", "(Ljava/lang/String;)Z") - CACHE_OPT(delete_backward, "deleteBackward", "()Z") - CACHE_OPT(clear_text, "clearText", "()Z") - CACHE_OPT(long_press_xy, "longPressXy", "(FFJ)Z") - CACHE_OPT(swipe_xy, "swipeXy", "(FFFF)Z") -#undef CACHE_OPT - - if (att) - (*g_jvm)->DetachCurrentThread(g_jvm); - - LOGI("Mob NIF loaded (Compose backend)"); - return 0; -} - -ERL_NIF_INIT(mob_nif, nif_funcs, nif_load, NULL, NULL, NULL) diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 3ef337ea..f9c05f80 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -23,9 +23,17 @@ //! nif_set_transition, nif_register_component, nif_deregister_component). //! The C-side `nif_load` calls `mob_nif_init_state` (exported here) //! to create the mutexes during BEAM init. -//! * iter 3d: remaining feature NIFs (storage, WebView, alert, -//! action_sheet, toast, native view components, lifecycle, -//! Mob.Device). Moves the NIF table itself here. mob_nif.c deleted. +//! * iter 3d (this iter): the finale. Remaining feature NIFs (color +//! scheme, exit_app, safe_area, haptic, clipboard, open_url, +//! share_text, launch notification, request_permission, +//! biometric, location ×3, camera ×4, photos_pick, files_pick, +//! audio ×5, motion ×2, scanner, notifications ×3, storage ×4, +//! alert/action_sheet/toast, webview ×4, background ×2, +//! Mob.Device ×7), the bridge bootstrap helpers +//! (_mob_ui_cache_class_impl, _mob_bridge_init_activity, +//! mob_set_startup_phase, mob_set_startup_error), all the +//! deliver_* event dispatchers, and the NIF table itself with +//! nif_load + the ERL_NIF_INIT entry point. mob_nif.c deleted. //! //! All exports use the C ABI so the C-side NIF table can reference them. @@ -1418,3 +1426,1507 @@ export fn nif_deregister_component( erts.enif_mutex_unlock(component_mutex); return erts.ok(env); } + +// ══════════════════════════════════════════════════════════════════════════ +// Phase 6b iter 3d — finale: bridge bootstrap, feature NIFs, deliver_* event +// dispatchers, NIF table, and the ERL_NIF_INIT entry point. After this iter +// mob_nif.c is gone — everything below was the last residency of native +// state and entry points in C. +// ══════════════════════════════════════════════════════════════════════════ + +const NIF_LOG_TAG: [*:0]const u8 = "MobNIF"; + +inline fn logi_nif(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_INFO, NIF_LOG_TAG, fmt, args); +} + +inline fn loge_nif(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_ERROR, NIF_LOG_TAG, fmt, args); +} + +// ── Bridge bootstrap helpers ───────────────────────────────────────────── +// Called from mob_beam.zig during BEAM startup, BEFORE nif_load runs. The +// startup_phase / startup_error paths must be safe to call when only +// `Bridge.cls` + `Bridge.set_startup_phase` / `Bridge.set_startup_error` +// are populated (which is what _mob_ui_cache_class_impl does first). + +/// `_mob_ui_cache_class_impl(jenv, bridge_class)` — invoked by +/// `mob_ui_cache_class` (in mob_beam.zig) from JNI_OnLoad. Caches the +/// MobBridge `jclass` as a global ref and pre-caches set_startup_phase / +/// set_startup_error so the BEAM launcher can drive the splash screen +/// before NIF load. +pub export fn _mob_ui_cache_class_impl(jenv_p: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void { + logi_nif("mob_ui_cache_class: looking up {s}", .{bridge_class}); + const cls = jni.findClass(jenv_p, bridge_class); + if (cls == null) { + loge_nif("mob_ui_cache_class: {s} not found", .{bridge_class}); + return; + } + Bridge.cls = jni.newGlobalRef(jenv_p, cls); + jni.deleteLocalRef(jenv_p, cls); + // Pre-cache startup status methods — needed before nif_load runs. + // These are optional (older MobBridge versions may not have them); + // clear any pending exception rather than aborting. + Bridge.set_startup_phase = jni.getStaticMethodID(jenv_p, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); + if (Bridge.set_startup_phase == null) jni.exceptionClear(jenv_p); + Bridge.set_startup_error = jni.getStaticMethodID(jenv_p, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); + if (Bridge.set_startup_error == null) jni.exceptionClear(jenv_p); + logi_nif("mob_ui_cache_class: {s} cached OK", .{bridge_class}); +} + +pub export fn mob_set_startup_phase(phase: [*:0]const u8) callconv(.c) void { + if (g_jvm == null or Bridge.cls == null or Bridge.set_startup_phase == null) return; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return; + const js = jni.newStringUTF(jenv, phase); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_startup_phase, js); + jni.deleteLocalRef(jenv, js); + detachIfAttached(attached); + logi_nif("startup: {s}", .{phase}); +} + +pub export fn mob_set_startup_error(err: [*:0]const u8) callconv(.c) void { + if (g_jvm == null or Bridge.cls == null or Bridge.set_startup_error == null) return; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return; + const js = jni.newStringUTF(jenv, err); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_startup_error, js); + jni.deleteLocalRef(jenv, js); + detachIfAttached(attached); + loge_nif("startup ERROR: {s}", .{err}); +} + +/// `_mob_bridge_init_activity` — invoked by `mob_init_bridge` (mob_beam.zig) +/// after the Activity global ref is set. Calls MobBridge.init(Activity) +/// which wires the Kotlin side to the running activity. +pub export fn _mob_bridge_init_activity(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void { + if (Bridge.cls == null) { + loge_nif("_mob_bridge_init_activity: Bridge.cls not cached", .{}); + return; + } + const init = jni.getStaticMethodID(env, Bridge.cls, "init", "(Landroid/app/Activity;)V"); + env.*.CallStaticVoidMethod.?(env, Bridge.cls, init, activity); + logi_nif("_mob_bridge_init_activity: MobBridge.init called", .{}); +} + +// ── Helpers for the feature NIFs below ─────────────────────────────────── + +/// Accept either a plain binary or an iolist (deep-flatten to binary). +/// Returns null on failure — the caller turns that into `badarg`. +fn getBinOrIolist(env: ?*erts.ErlNifEnv, term: erts.ERL_NIF_TERM) ?erts.ErlNifBinary { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, term, &bin) != 0) return bin; + if (erts.enif_inspect_iolist_as_binary(env, term, &bin) != 0) return bin; + return null; +} + +/// Heap-allocate a NUL-terminated copy of an `ErlNifBinary` for JNI's +/// NewStringUTF. Returns null on OOM. Caller frees via `freeCString`. +fn binToCString(bin: erts.ErlNifBinary) ?[*:0]u8 { + const buf_ptr = jni.malloc(bin.size + 1) orelse return null; + const dst: [*]u8 = @ptrCast(buf_ptr); + @memcpy(dst[0..bin.size], bin.data[0..bin.size]); + dst[bin.size] = 0; + return @ptrCast(buf_ptr); +} + +inline fn freeCString(p: ?[*:0]u8) void { + if (p) |ptr| jni.free(@as(?*anyopaque, @ptrCast(ptr))); +} + +/// Pack an ErlNifPid into a jlong for the JNI-side delivery handle. Kotlin +/// hands it back unchanged when it calls one of the mob_deliver_* hooks; +/// we round-trip via `pidFromLong`. ErlNifPid is `{ ERL_NIF_TERM pid; }` +/// which is `c_ulong` on aarch64 — same size as jlong — so a bitcast is +/// equivalent to the C `memcpy(&jpid, &pid, sizeof(...))` pattern. +inline fn pidToJlong(pid: erts.ErlNifPid) jni.JLong { + return @bitCast(pid.pid); +} + +inline fn pidFromLong(jpid: jni.JLong) erts.ErlNifPid { + return .{ .pid = @bitCast(jpid) }; +} + +/// Call `MobBridge.(pid_long, arg)` — the standard shape for +/// async device-capability NIFs (location, camera, audio_play, etc.). +/// Returns the `:ok` atom unconditionally; results land later via one of +/// the mob_deliver_* JNI hooks. `arg` may be null for void-of-pid methods. +fn callBridgePidStr(env: ?*erts.ErlNifEnv, method: jni.JMethodID, pid: erts.ErlNifPid, arg: ?[*:0]const u8) erts.ERL_NIF_TERM { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jarg: jni.JString = if (arg) |a| jni.newStringUTF(jenv, a) else null; + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, method, pidToJlong(pid), jarg); + if (jarg != null) jni.deleteLocalRef(jenv, jarg); + detachIfAttached(attached); + return erts.ok(env); +} + +fn callBridgePidStr2(env: ?*erts.ErlNifEnv, method: jni.JMethodID, pid: erts.ErlNifPid, a1: ?[*:0]const u8, a2: ?[*:0]const u8) erts.ERL_NIF_TERM { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const j1: jni.JString = if (a1) |a| jni.newStringUTF(jenv, a) else null; + const j2: jni.JString = if (a2) |a| jni.newStringUTF(jenv, a) else null; + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, method, pidToJlong(pid), j1, j2); + if (j1 != null) jni.deleteLocalRef(jenv, j1); + if (j2 != null) jni.deleteLocalRef(jenv, j2); + detachIfAttached(attached); + return erts.ok(env); +} + +/// Read a jstring into an `ErlNifBinary` via UTF-8. Returns the binary +/// term + 1 (success) or 0 (null jstring / GetStringUTFChars failed). +/// Deletes the local ref on success. +fn jstringToBinaryTerm(env: ?*erts.ErlNifEnv, jenv: *jni.JNIEnv, js: jni.JString) ?erts.ERL_NIF_TERM { + if (js == null) return null; + const utf = jni.getStringUTFChars(jenv, js) orelse return null; + const len = jni.strlen(utf); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf[0..len]); + jni.releaseStringUTFChars(jenv, js, utf); + jni.deleteLocalRef(jenv, js); + return erts.enif_make_binary(env, &bin); +} + +// ── Core feature NIFs ──────────────────────────────────────────────────── + +// nif_color_scheme/0 — :light | :dark. Returns :light if the optional +// MobBridge.getColorScheme() isn't compiled into the app. +export fn nif_color_scheme( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.get_color_scheme == null) return erts.atom(env, "light"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "light"); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.get_color_scheme); + var out = erts.atom(env, "light"); + if (result != null) { + if (jni.getStringUTFChars(jenv, result)) |str| { + if (jni.strncmp(str, "dark", 4) == 0 and str[4] == 0) { + out = erts.atom(env, "dark"); + } + jni.releaseStringUTFChars(jenv, result, str); + } + jni.deleteLocalRef(jenv, result); + } + detachIfAttached(attached); + return out; +} + +// nif_exit_app/0 — Activity.moveTaskToBack(true). Called by Mob.Screen +// when the back gesture fires at the root of the nav stack. +export fn nif_exit_app( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.move_to_back); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_safe_area/0 — {Top, Right, Bottom, Left} in dp via +// MobBridge.getSafeArea(). The Kotlin side returns float[4] in +// {top, right, bottom, left} order. +export fn nif_safe_area( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + var vals: [4]f32 = @splat(0); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.get_safe_area); + if (arr != null) { + jni.getFloatArrayRegion(jenv, arr, 0, 4, &vals); + jni.deleteLocalRef(jenv, arr); + } + detachIfAttached(attached); + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + erts.enif_make_double(env, @floatCast(vals[3])), + }); +} + +// nif_haptic/1 — pass an atom (heavy/medium/light/...) to +// MobBridge.haptic(String). +export fn nif_haptic( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var type_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtype = jni.newStringUTF(jenv, jni.asCStr(&type_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.haptic, jtype); + jni.deleteLocalRef(jenv, jtype); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_clipboard_put/1 — ClipboardManager.setPrimaryClip via Kotlin. +export fn nif_clipboard_put( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const text = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(text); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.clipboard_put, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_clipboard_get/0 — returns {:ok, Binary} or :empty. +export fn nif_clipboard_get( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.clipboard_get); + var out: erts.ERL_NIF_TERM = undefined; + if (jstringToBinaryTerm(env, jenv, result)) |bin_term| { + out = erts.makeTuple(env, .{ erts.atom(env, "ok"), bin_term }); + } else { + out = erts.atom(env, "empty"); + } + detachIfAttached(attached); + return out; +} + +// nif_open_url/1 — Intent ACTION_VIEW with the URI. +export fn nif_open_url( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const url = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(url); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jurl = jni.newStringUTF(jenv, url); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.open_url, jurl); + jni.deleteLocalRef(jenv, jurl); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_share_text/1 — system share sheet (Intent ACTION_SEND text/plain). +export fn nif_share_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const text = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(text); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.share_text, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── Launch notification (written from Kotlin on cold start) ────────────── +// MobBridge.setLaunchNotification(json) → mob_set_launch_notification(json). +// Apps call Mob.Device.take_launch_notification/0 → nif_take_launch_notification +// to consume it. Guarded by g_launch_notif_mutex (lazily created in nif_load). + +var g_launch_notif_json: ?[*:0]u8 = null; +var g_launch_notif_mutex: ?*erts.ErlNifMutex = null; + +pub export fn mob_set_launch_notification(json: ?[*:0]const u8) callconv(.c) void { + const mutex = g_launch_notif_mutex orelse return; + erts.enif_mutex_lock(mutex); + defer erts.enif_mutex_unlock(mutex); + if (g_launch_notif_json) |old| jni.free(@as(?*anyopaque, @ptrCast(old))); + g_launch_notif_json = if (json) |j| jni.strdup(j) else null; +} + +export fn nif_take_launch_notification( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + const mutex = g_launch_notif_mutex orelse return erts.atom(env, "none"); + erts.enif_mutex_lock(mutex); + const taken = g_launch_notif_json; + g_launch_notif_json = null; + erts.enif_mutex_unlock(mutex); + const json = taken orelse return erts.atom(env, "none"); + const len = jni.strlen(json); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], json[0..len]); + jni.free(@as(?*anyopaque, @ptrCast(json))); + return erts.enif_make_binary(env, &bin); +} + +// ── Async result delivery (called from Kotlin via JNI) ─────────────────── +// Each `mob_deliver_*` is invoked by the Kotlin side (after an async +// operation like locationGetOnce or cameraCapturePhoto completes) with +// the pid encoded as a jlong + the typed result. We rebuild an ErlNifPid +// and ship the appropriate {:tag, payload} message. + +/// `mob_nif_deliver_json` exists for legacy callers in beam_jni.c — it's a +/// no-op. Typed dispatchers below cover the real surface. +pub export fn mob_nif_deliver_json(pid_long: jni.JLong, json_str: [*:0]const u8) callconv(.c) void { + _ = pid_long; + _ = json_str; +} + +pub export fn mob_deliver_atom2(jpid: jni.JLong, a1: [*:0]const u8, a2: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, a1), + erts.enif_make_atom(env, a2), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_atom3(jpid: jni.JLong, a1: [*:0]const u8, a2: [*:0]const u8, a3: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, a1), + erts.enif_make_atom(env, a2), + erts.enif_make_atom(env, a3), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_location(jpid: jni.JLong, lat: f64, lon: f64, acc: f64, alt: f64) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "lat"), + erts.atom(env, "lon"), + erts.atom(env, "accuracy"), + erts.atom(env, "altitude"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, lat), + erts.enif_make_double(env, lon), + erts.enif_make_double(env, acc), + erts.enif_make_double(env, alt), + }; + const map = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ erts.atom(env, "location"), map }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_motion( + jpid: jni.JLong, + ax: f64, + ay: f64, + az: f64, + gx: f64, + gy: f64, + gz: f64, + ts: i64, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const accel = erts.makeTuple(env, .{ + erts.enif_make_double(env, ax), + erts.enif_make_double(env, ay), + erts.enif_make_double(env, az), + }); + const gyro = erts.makeTuple(env, .{ + erts.enif_make_double(env, gx), + erts.enif_make_double(env, gy), + erts.enif_make_double(env, gz), + }); + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "accel"), + erts.atom(env, "gyro"), + erts.atom(env, "timestamp"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + accel, + gyro, + erts.enif_make_int64(env, ts), + }; + const map = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ erts.atom(env, "motion"), map }); + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `{:webview, tag, binary}`. When `jpid == 0` the message routes to the +/// :mob_screen registered process; otherwise to the explicit pid. +fn deliverWebviewBinary(jpid: jni.JLong, comptime tag: [:0]const u8, utf8: [*:0]const u8) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (jpid != 0) { + pid = pidFromLong(jpid); + } else if (erts.enif_whereis_pid(env, erts.atom(env, "mob_screen"), &pid) == 0) { + return; + } + const len = jni.strlen(utf8); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf8[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "webview"), + erts.atom(env, tag), + erts.enif_make_binary(env, &bin), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_webview_message(jpid: jni.JLong, json: [*:0]const u8) callconv(.c) void { + deliverWebviewBinary(jpid, "message", json); +} + +pub export fn mob_deliver_webview_blocked(jpid: jni.JLong, url: [*:0]const u8) callconv(.c) void { + deliverWebviewBinary(jpid, "blocked", url); +} + +/// `mob_deliver_file_result` — used by camera/photos/files/audio/scanner +/// capture results. Two shapes: +/// * `{event_atom, :cancelled}` when json_items is null OR "cancelled" +/// * `{:mob_file_result, event_bin, sub_bin, json_bin}` otherwise +pub export fn mob_deliver_file_result( + jpid: jni.JLong, + event: [*:0]const u8, + sub: [*:0]const u8, + json_items: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const cancelled = blk: { + const j = json_items orelse break :blk true; + const span = std.mem.span(j); + break :blk std.mem.eql(u8, span, "cancelled"); + }; + + const msg = if (cancelled) erts.makeTuple(env, .{ + erts.enif_make_atom(env, event), + erts.atom(env, "cancelled"), + }) else build: { + const j = json_items.?; + const jl = jni.strlen(j); + const el = jni.strlen(event); + const sl = jni.strlen(sub); + var jb: erts.ErlNifBinary = undefined; + var eb: erts.ErlNifBinary = undefined; + var sb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(jl, &jb); + _ = erts.enif_alloc_binary(el, &eb); + _ = erts.enif_alloc_binary(sl, &sb); + @memcpy(jb.data[0..jl], j[0..jl]); + @memcpy(eb.data[0..el], event[0..el]); + @memcpy(sb.data[0..sl], sub[0..sl]); + break :build erts.makeTuple(env, .{ + erts.atom(env, "mob_file_result"), + erts.enif_make_binary(env, &eb), + erts.enif_make_binary(env, &sb), + erts.enif_make_binary(env, &jb), + }); + }; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_push_token(jpid: jni.JLong, token: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const len = jni.strlen(token); + var tb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &tb); + @memcpy(tb.data[0..len], token[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "push_token"), + erts.atom(env, "android"), + erts.enif_make_binary(env, &tb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_notification(jpid: jni.JLong, json: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const len = jni.strlen(json); + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + @memcpy(jb.data[0..len], json[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "mob_launch_notification"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `mob_deliver_alert_action` — called from beam_jni.c when a dialog +/// button is tapped. Routes to :mob_screen as {:alert, action_atom}. +pub export fn mob_deliver_alert_action(action: [*:0]const u8) callconv(.c) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (erts.enif_whereis_pid(env, erts.atom(env, "mob_screen"), &pid) == 0) return; + const msg = erts.makeTuple(env, .{ + erts.atom(env, "alert"), + erts.enif_make_atom(env, action), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Capability NIFs (thin shims to Kotlin) ─────────────────────────────── + +export fn nif_request_permission( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var cap_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &cap_buf, cap_buf.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.request_permission, pid, jni.asCStr(&cap_buf)); +} + +export fn nif_biometric_authenticate( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + var reason: [256]u8 = @splat(0); + if (bin.size + 1 <= reason.len) { + @memcpy(reason[0..bin.size], bin.data[0..bin.size]); + reason[bin.size] = 0; + } else { + // Truncate. Matches the C original's defensive truncate-or-default. + @memcpy(reason[0 .. reason.len - 1], bin.data[0 .. reason.len - 1]); + reason[reason.len - 1] = 0; + } + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.biometric_authenticate, pid, jni.asCStr(&reason)); +} + +export fn nif_location_get_once( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.location_get_once, pid, "balanced"); +} + +export fn nif_location_start( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var acc_buf: [16]u8 = @splat(0); + jni.copyZ(&acc_buf, "balanced"); + _ = erts.enif_get_atom(env, argv[0], &acc_buf, acc_buf.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.location_start, pid, jni.asCStr(&acc_buf)); +} + +export fn nif_location_stop( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.location_stop); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_camera_capture_photo( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var qual: [16]u8 = @splat(0); + jni.copyZ(&qual, "high"); + _ = erts.enif_get_atom(env, argv[0], &qual, qual.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.camera_capture_photo, pid, jni.asCStr(&qual)); +} + +export fn nif_camera_capture_video( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var max_dur: c_int = 60; + _ = erts.enif_get_int(env, argv[0], &max_dur); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + var dur_buf: [16]u8 = @splat(0); + _ = std.fmt.bufPrint(&dur_buf, "{d}", .{max_dur}) catch {}; + return callBridgePidStr(env, Bridge.camera_capture_video, pid, jni.asCStr(&dur_buf)); +} + +export fn nif_camera_start_preview( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.camera_start_preview, pid, json); +} + +export fn nif_camera_stop_preview( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.camera_stop_preview); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_photos_pick( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var max: c_int = 1; + _ = erts.enif_get_int(env, argv[0], &max); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + var max_buf: [16]u8 = @splat(0); + _ = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch {}; + return callBridgePidStr(env, Bridge.photos_pick, pid, jni.asCStr(&max_buf)); +} + +export fn nif_files_pick( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.files_pick, pid, json); +} + +export fn nif_audio_start_recording( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.audio_start_recording, pid, json); +} + +export fn nif_audio_stop_recording( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_stop_recording); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_audio_play( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const path_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const opts_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const path = binToCString(path_bin) orelse return erts.atom(env, "error"); + defer freeCString(path); + const opts = binToCString(opts_bin) orelse return erts.atom(env, "error"); + defer freeCString(opts); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr2(env, Bridge.audio_play, pid, path, opts); +} + +export fn nif_audio_stop_playback( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_stop_playback); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_audio_set_volume( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var vol: f64 = 1.0; + _ = erts.enif_get_double(env, argv[0], &vol); + var vol_buf: [32]u8 = @splat(0); + _ = std.fmt.bufPrint(&vol_buf, "{d:.6}", .{vol}) catch {}; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jvol = jni.newStringUTF(jenv, jni.asCStr(&vol_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_set_volume, jvol); + jni.deleteLocalRef(jenv, jvol); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_motion_start( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var interval_ms: c_int = 100; + _ = erts.enif_get_int(env, argv[1], &interval_ms); + var ival_buf: [16]u8 = @splat(0); + _ = std.fmt.bufPrint(&ival_buf, "{d}", .{interval_ms}) catch {}; + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.motion_start, pid, jni.asCStr(&ival_buf)); +} + +export fn nif_motion_stop( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.motion_stop); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_scanner_scan( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.scanner_scan, pid, json); +} + +export fn nif_notify_schedule( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.notify_schedule, pid, json); +} + +export fn nif_notify_cancel( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + var nid: [256]u8 = @splat(0); + const copy = @min(bin.size, nid.len - 1); + @memcpy(nid[0..copy], bin.data[0..copy]); + nid[copy] = 0; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const js = jni.newStringUTF(jenv, jni.asCStr(&nid)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.notify_cancel, js); + jni.deleteLocalRef(jenv, js); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_notify_register_push( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.notify_register_push, pid, null); +} + +// ── Storage ────────────────────────────────────────────────────────────── + +export fn nif_storage_dir( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var loc: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &loc, loc.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jloc = jni.newStringUTF(jenv, jni.asCStr(&loc)); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.storage_dir, jloc); + jni.deleteLocalRef(jenv, jloc); + const out = jstringToBinaryTerm(env, jenv, result) orelse erts.atom(env, "nil"); + detachIfAttached(attached); + return out; +} + +export fn nif_storage_save_to_media_store( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const path = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(path); + var type_buf: [16]u8 = @splat(0); + jni.copyZ(&type_buf, "auto"); + _ = erts.enif_get_atom(env, argv[1], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr2(env, Bridge.storage_save_to_media_store, pid, path, jni.asCStr(&type_buf)); +} + +export fn nif_storage_external_files_dir( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var type_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtype = jni.newStringUTF(jenv, jni.asCStr(&type_buf)); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.storage_external_files_dir, jtype); + jni.deleteLocalRef(jenv, jtype); + const out = jstringToBinaryTerm(env, jenv, result) orelse erts.atom(env, "nil"); + detachIfAttached(attached); + return out; +} + +/// iOS-only — Android has no equivalent. Returns `{:error, :not_supported}`. +export fn nif_storage_save_to_photo_library( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.errorTuple(env, erts.atom(env, "not_supported")); +} + +// ── Alert / action sheet / toast ───────────────────────────────────────── + +export fn nif_alert_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const title_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const msg_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const btns_bin = getBinOrIolist(env, argv[2]) orelse return erts.badarg(env); + + const title = binToCString(title_bin) orelse return erts.atom(env, "error"); + defer freeCString(title); + const message = binToCString(msg_bin) orelse return erts.atom(env, "error"); + defer freeCString(message); + const btns = binToCString(btns_bin) orelse return erts.atom(env, "error"); + defer freeCString(btns); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtitle = jni.newStringUTF(jenv, title); + const jmessage = jni.newStringUTF(jenv, message); + const jbtns = jni.newStringUTF(jenv, btns); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.alert_show, jtitle, jmessage, jbtns); + jni.deleteLocalRef(jenv, jtitle); + jni.deleteLocalRef(jenv, jmessage); + jni.deleteLocalRef(jenv, jbtns); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_action_sheet_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const title_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const btns_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const title = binToCString(title_bin) orelse return erts.atom(env, "error"); + defer freeCString(title); + const btns = binToCString(btns_bin) orelse return erts.atom(env, "error"); + defer freeCString(btns); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtitle = jni.newStringUTF(jenv, title); + const jbtns = jni.newStringUTF(jenv, btns); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.action_sheet_show, jtitle, jbtns); + jni.deleteLocalRef(jenv, jtitle); + jni.deleteLocalRef(jenv, jbtns); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_toast_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const msg_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + var dur: [8]u8 = @splat(0); + jni.copyZ(&dur, "short"); + _ = erts.enif_get_atom(env, argv[1], &dur, dur.len, erts.ERL_NIF_LATIN1); + const msg = binToCString(msg_bin) orelse return erts.atom(env, "error"); + defer freeCString(msg); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jmsg = jni.newStringUTF(jenv, msg); + const jdur = jni.newStringUTF(jenv, jni.asCStr(&dur)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.toast_show, jmsg, jdur); + jni.deleteLocalRef(jenv, jmsg); + jni.deleteLocalRef(jenv, jdur); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── WebView ────────────────────────────────────────────────────────────── + +export fn nif_webview_eval_js( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const code = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(code); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jcode = jni.newStringUTF(jenv, code); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_eval_js, jcode); + jni.deleteLocalRef(jenv, jcode); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_webview_post_message( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jjson = jni.newStringUTF(jenv, json); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_post_message, jjson); + jni.deleteLocalRef(jenv, jjson); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_webview_can_go_back( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "false"); + const result = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.webview_can_go_back); + detachIfAttached(attached); + return if (result != 0) erts.atom(env, "true") else erts.atom(env, "false"); +} + +export fn nif_webview_go_back( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_go_back); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── Background (foreground service) ────────────────────────────────────── + +export fn nif_background_keep_alive( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.background_keep_alive); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_background_stop( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.background_stop); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── Mob.Device — lifecycle events + queries ────────────────────────────── +// Android implementation is partial — only `:appearance` (color scheme +// changes from MainActivity.onConfigurationChanged) is wired today. The +// rest (battery, thermal, lifecycle) is queued behind ProcessLifecycleOwner +// + ComponentCallbacks2 plumbing. Until then the dispatcher pid is stored +// so what IS wired (color scheme) can deliver, and the query NIFs return +// reasonable defaults. + +var g_device_dispatcher_pid: erts.ErlNifPid = .{ .pid = 0 }; +var g_device_dispatcher_set: bool = false; + +fn deviceSendAtomPayload(comptime tag: [:0]const u8, atom_name: [*:0]const u8, payload_atom_str: [*:0]const u8) void { + if (!g_device_dispatcher_set) return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.atom(env, tag), + erts.enif_make_atom(env, atom_name), + erts.enif_make_atom(env, payload_atom_str), + }); + var pid = g_device_dispatcher_pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +/// Called from beam_jni.c's `Java_..._MobBridge_nativeNotifyColorScheme` +/// when MainActivity.onConfigurationChanged sees a uiMode flip. `scheme` +/// must be "light" or "dark". +pub export fn mob_send_color_scheme_changed(scheme: ?[*:0]const u8) callconv(.c) void { + const s = scheme orelse return; + deviceSendAtomPayload("mob_device", "color_scheme_changed", s); +} + +export fn nif_device_set_dispatcher( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + if (erts.enif_get_local_pid(env, argv[0], &pid) == 0) return erts.badarg(env); + g_device_dispatcher_pid = pid; + g_device_dispatcher_set = true; + return erts.ok(env); +} + +export fn nif_device_battery_state( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): query BatteryManager. For now, unknown / -1. + return erts.makeTuple(env, .{ + erts.atom(env, "unknown"), + erts.enif_make_int(env, -1), + }); +} + +export fn nif_device_thermal_state( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): PowerManager.getCurrentThermalStatus() (API 29+). + return erts.atom(env, "nominal"); +} + +export fn nif_device_low_power_mode( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): PowerManager.isPowerSaveMode(). + return erts.atom(env, "false"); +} + +export fn nif_device_foreground( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): track via ProcessLifecycleOwner. + return erts.atom(env, "true"); +} + +export fn nif_device_os_version( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): Build.VERSION.RELEASE via JNI. + return erts.enif_make_string(env, "", erts.ERL_NIF_LATIN1); +} + +export fn nif_device_model( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): Build.MODEL via JNI. + return erts.enif_make_string(env, "Android", erts.ERL_NIF_LATIN1); +} + +// ── nif_load: cache all method IDs at BEAM startup ─────────────────────── + +/// Required-method helper. Returns false if the method isn't on the +/// Kotlin side — caller turns that into a `return -1` from nif_load. +inline fn cacheRequired(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const u8, field: *jni.JMethodID) bool { + field.* = jni.getStaticMethodID(jenv, Bridge.cls, name, sig); + if (field.* == null) { + loge_nif("nif_load: {s} not found", .{name}); + return false; + } + return true; +} + +/// Optional-method helper. Clears any JNI exception and logs at INFO. +inline fn cacheOptional(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const u8, field: *jni.JMethodID) void { + field.* = jni.getStaticMethodID(jenv, Bridge.cls, name, sig); + if (field.* == null) { + jni.exceptionClear(jenv); + logi_nif("nif_load: {s} not found (optional)", .{name}); + } +} + +fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) callconv(.c) c_int { + _ = env; + _ = priv; + _ = info; + logi_nif("nif_load: entered, Bridge.cls={any}", .{Bridge.cls}); + if (Bridge.cls == null) { + loge_nif("Bridge.cls not cached — was mob_ui_cache_class called?", .{}); + return -1; + } + + // tap_mutex + component_mutex are created here (mob_nif_init_state is + // a Zig-side export, but for the all-Zig finale we just call the + // initialiser directly — no C boundary to cross). + if (mob_nif_init_state() != 0) { + loge_nif("nif_load: mob_nif_init_state failed (mutex create)", .{}); + return -1; + } + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse { + loge_nif("nif_load: get_jenv returned null", .{}); + return -1; + }; + defer detachIfAttached(attached); + + if (!cacheRequired(jenv, "setRootJson", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.set_root)) return -1; + if (!cacheRequired(jenv, "moveToBack", "()V", &Bridge.move_to_back)) return -1; + if (!cacheRequired(jenv, "getSafeArea", "()[F", &Bridge.get_safe_area)) return -1; + + // getColorScheme() is optional — apps that haven't been regenerated + // since it was added still load fine; nif_color_scheme falls back to + // :light. + cacheOptional(jenv, "getColorScheme", "()Ljava/lang/String;", &Bridge.get_color_scheme); + + if (!cacheRequired(jenv, "haptic", "(Ljava/lang/String;)V", &Bridge.haptic)) return -1; + if (!cacheRequired(jenv, "clipboardPut", "(Ljava/lang/String;)V", &Bridge.clipboard_put)) return -1; + if (!cacheRequired(jenv, "clipboardGet", "()Ljava/lang/String;", &Bridge.clipboard_get)) return -1; + if (!cacheRequired(jenv, "shareText", "(Ljava/lang/String;)V", &Bridge.share_text)) return -1; + if (!cacheRequired(jenv, "openUrl", "(Ljava/lang/String;)V", &Bridge.open_url)) return -1; + + // Async device-capability methods. Most take (J, String) where J is + // the pid as a long. + if (!cacheRequired(jenv, "request_permission", "(JLjava/lang/String;)V", &Bridge.request_permission)) return -1; + if (!cacheRequired(jenv, "biometric_authenticate", "(JLjava/lang/String;)V", &Bridge.biometric_authenticate)) return -1; + if (!cacheRequired(jenv, "location_get_once", "(JLjava/lang/String;)V", &Bridge.location_get_once)) return -1; + if (!cacheRequired(jenv, "location_start", "(JLjava/lang/String;)V", &Bridge.location_start)) return -1; + if (!cacheRequired(jenv, "location_stop", "()V", &Bridge.location_stop)) return -1; + if (!cacheRequired(jenv, "camera_capture_photo", "(JLjava/lang/String;)V", &Bridge.camera_capture_photo)) return -1; + if (!cacheRequired(jenv, "camera_capture_video", "(JLjava/lang/String;)V", &Bridge.camera_capture_video)) return -1; + if (!cacheRequired(jenv, "camera_start_preview", "(JLjava/lang/String;)V", &Bridge.camera_start_preview)) return -1; + if (!cacheRequired(jenv, "camera_stop_preview", "()V", &Bridge.camera_stop_preview)) return -1; + if (!cacheRequired(jenv, "photos_pick", "(JLjava/lang/String;)V", &Bridge.photos_pick)) return -1; + if (!cacheRequired(jenv, "files_pick", "(JLjava/lang/String;)V", &Bridge.files_pick)) return -1; + if (!cacheRequired(jenv, "audio_start_recording", "(JLjava/lang/String;)V", &Bridge.audio_start_recording)) return -1; + if (!cacheRequired(jenv, "audio_stop_recording", "()V", &Bridge.audio_stop_recording)) return -1; + if (!cacheRequired(jenv, "audio_play", "(JLjava/lang/String;Ljava/lang/String;)V", &Bridge.audio_play)) return -1; + if (!cacheRequired(jenv, "audio_stop_playback", "()V", &Bridge.audio_stop_playback)) return -1; + if (!cacheRequired(jenv, "audio_set_volume", "(Ljava/lang/String;)V", &Bridge.audio_set_volume)) return -1; + if (!cacheRequired(jenv, "storage_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_dir)) return -1; + if (!cacheRequired(jenv, "storage_save_to_media_store", "(JLjava/lang/String;Ljava/lang/String;)V", &Bridge.storage_save_to_media_store)) return -1; + if (!cacheRequired(jenv, "storage_external_files_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_external_files_dir)) return -1; + if (!cacheRequired(jenv, "alert_show", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", &Bridge.alert_show)) return -1; + if (!cacheRequired(jenv, "action_sheet_show", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.action_sheet_show)) return -1; + if (!cacheRequired(jenv, "toast_show", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.toast_show)) return -1; + if (!cacheRequired(jenv, "webview_eval_js", "(Ljava/lang/String;)V", &Bridge.webview_eval_js)) return -1; + if (!cacheRequired(jenv, "webview_post_message", "(Ljava/lang/String;)V", &Bridge.webview_post_message)) return -1; + if (!cacheRequired(jenv, "webview_can_go_back", "()Z", &Bridge.webview_can_go_back)) return -1; + if (!cacheRequired(jenv, "webview_go_back", "()V", &Bridge.webview_go_back)) return -1; + if (!cacheRequired(jenv, "motion_start", "(JLjava/lang/String;)V", &Bridge.motion_start)) return -1; + if (!cacheRequired(jenv, "motion_stop", "()V", &Bridge.motion_stop)) return -1; + if (!cacheRequired(jenv, "scanner_scan", "(JLjava/lang/String;)V", &Bridge.scanner_scan)) return -1; + if (!cacheRequired(jenv, "notify_schedule", "(JLjava/lang/String;)V", &Bridge.notify_schedule)) return -1; + if (!cacheRequired(jenv, "notify_cancel", "(Ljava/lang/String;)V", &Bridge.notify_cancel)) return -1; + if (!cacheRequired(jenv, "notify_register_push", "(JLjava/lang/String;)V", &Bridge.notify_register_push)) return -1; + if (!cacheRequired(jenv, "background_keep_alive", "()V", &Bridge.background_keep_alive)) return -1; + if (!cacheRequired(jenv, "background_stop", "()V", &Bridge.background_stop)) return -1; + + g_launch_notif_mutex = erts.enif_mutex_create("mob_launch_notif_mutex"); + if (g_launch_notif_mutex == null) { + loge_nif("nif_load: failed to create launch notif mutex", .{}); + return -1; + } + + // Test harness method IDs — optional. Apps without the harness build + // (release variants, downstream consumers that don't link it) won't + // have these and that's fine; the test NIFs return :not_loaded. + cacheOptional(jenv, "uiTree", "()Ljava/lang/String;", &Bridge.ui_tree); + cacheOptional(jenv, "uiViewTree", "()Ljava/lang/String;", &Bridge.ui_view_tree); + cacheOptional(jenv, "screenInfo", "()[F", &Bridge.screen_info); + cacheOptional(jenv, "tapXy", "(FF)Z", &Bridge.tap_xy); + cacheOptional(jenv, "tapByLabel", "(Ljava/lang/String;)Z", &Bridge.tap_by_label); + cacheOptional(jenv, "typeText", "(Ljava/lang/String;)Z", &Bridge.type_text); + cacheOptional(jenv, "deleteBackward", "()Z", &Bridge.delete_backward); + cacheOptional(jenv, "clearText", "()Z", &Bridge.clear_text); + cacheOptional(jenv, "longPressXy", "(FFJ)Z", &Bridge.long_press_xy); + cacheOptional(jenv, "swipeXy", "(FFFF)Z", &Bridge.swipe_xy); + + logi_nif("Mob NIF loaded (Compose backend)", .{}); + return 0; +} + +// ── NIF table + ERL_NIF_INIT entry point ───────────────────────────────── +// Replaces the static `ErlNifFunc nif_funcs[]` + `ERL_NIF_INIT` macro +// that used to live at the bottom of mob_nif.c. The entry point is the +// `_nif_init` symbol the BEAM looks up from the driver_tab — +// driver_tab_android.zig already extern-declares `mob_nif_nif_init` for +// the static-NIF link path. + +const nif_funcs = [_]erts.ErlNifFunc{ + // Test harness first — matches the iOS nif_funcs[] ordering convention. + .{ .name = "ui_tree", .arity = 0, .fptr = nif_ui_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "ui_view_tree", .arity = 0, .fptr = nif_ui_view_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "ax_action", .arity = 2, .fptr = nif_ax_action, .flags = 0 }, + .{ .name = "ax_action_at_xy", .arity = 3, .fptr = nif_ax_action_at_xy, .flags = 0 }, + .{ .name = "ui_debug", .arity = 0, .fptr = nif_ui_debug, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "screen_info", .arity = 0, .fptr = nif_screen_info, .flags = 0 }, + .{ .name = "tap", .arity = 1, .fptr = nif_tap, .flags = 0 }, + .{ .name = "tap_xy", .arity = 2, .fptr = nif_tap_xy, .flags = 0 }, + .{ .name = "type_text", .arity = 1, .fptr = nif_type_text, .flags = 0 }, + .{ .name = "delete_backward", .arity = 0, .fptr = nif_delete_backward, .flags = 0 }, + .{ .name = "key_press", .arity = 1, .fptr = nif_key_press, .flags = 0 }, + .{ .name = "clear_text", .arity = 0, .fptr = nif_clear_text, .flags = 0 }, + .{ .name = "long_press_xy", .arity = 3, .fptr = nif_long_press_xy, .flags = 0 }, + .{ .name = "swipe_xy", .arity = 4, .fptr = nif_swipe_xy, .flags = 0 }, + // Core mob functions. + .{ .name = "platform", .arity = 0, .fptr = nif_platform, .flags = 0 }, + .{ .name = "color_scheme", .arity = 0, .fptr = nif_color_scheme, .flags = 0 }, + .{ .name = "log", .arity = 1, .fptr = nif_log, .flags = 0 }, + .{ .name = "log", .arity = 2, .fptr = nif_log2, .flags = 0 }, + .{ .name = "set_transition", .arity = 1, .fptr = nif_set_transition, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "set_root", .arity = 1, .fptr = nif_set_root, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "register_tap", .arity = 1, .fptr = nif_register_tap, .flags = 0 }, + .{ .name = "clear_taps", .arity = 0, .fptr = nif_clear_taps, .flags = 0 }, + .{ .name = "exit_app", .arity = 0, .fptr = nif_exit_app, .flags = 0 }, + .{ .name = "safe_area", .arity = 0, .fptr = nif_safe_area, .flags = 0 }, + .{ .name = "haptic", .arity = 1, .fptr = nif_haptic, .flags = 0 }, + .{ .name = "clipboard_put", .arity = 1, .fptr = nif_clipboard_put, .flags = 0 }, + .{ .name = "clipboard_get", .arity = 0, .fptr = nif_clipboard_get, .flags = 0 }, + .{ .name = "share_text", .arity = 1, .fptr = nif_share_text, .flags = 0 }, + .{ .name = "open_url", .arity = 1, .fptr = nif_open_url, .flags = 0 }, + .{ .name = "request_permission", .arity = 1, .fptr = nif_request_permission, .flags = 0 }, + .{ .name = "biometric_authenticate", .arity = 1, .fptr = nif_biometric_authenticate, .flags = 0 }, + .{ .name = "location_get_once", .arity = 0, .fptr = nif_location_get_once, .flags = 0 }, + .{ .name = "location_start", .arity = 1, .fptr = nif_location_start, .flags = 0 }, + .{ .name = "location_stop", .arity = 0, .fptr = nif_location_stop, .flags = 0 }, + .{ .name = "camera_capture_photo", .arity = 1, .fptr = nif_camera_capture_photo, .flags = 0 }, + .{ .name = "camera_capture_video", .arity = 1, .fptr = nif_camera_capture_video, .flags = 0 }, + .{ .name = "camera_start_preview", .arity = 1, .fptr = nif_camera_start_preview, .flags = 0 }, + .{ .name = "camera_stop_preview", .arity = 0, .fptr = nif_camera_stop_preview, .flags = 0 }, + .{ .name = "photos_pick", .arity = 2, .fptr = nif_photos_pick, .flags = 0 }, + .{ .name = "files_pick", .arity = 1, .fptr = nif_files_pick, .flags = 0 }, + .{ .name = "audio_start_recording", .arity = 1, .fptr = nif_audio_start_recording, .flags = 0 }, + .{ .name = "audio_stop_recording", .arity = 0, .fptr = nif_audio_stop_recording, .flags = 0 }, + .{ .name = "audio_play", .arity = 2, .fptr = nif_audio_play, .flags = 0 }, + .{ .name = "audio_stop_playback", .arity = 0, .fptr = nif_audio_stop_playback, .flags = 0 }, + .{ .name = "audio_set_volume", .arity = 1, .fptr = nif_audio_set_volume, .flags = 0 }, + .{ .name = "motion_start", .arity = 2, .fptr = nif_motion_start, .flags = 0 }, + .{ .name = "motion_stop", .arity = 0, .fptr = nif_motion_stop, .flags = 0 }, + .{ .name = "scanner_scan", .arity = 1, .fptr = nif_scanner_scan, .flags = 0 }, + .{ .name = "notify_schedule", .arity = 1, .fptr = nif_notify_schedule, .flags = 0 }, + .{ .name = "notify_cancel", .arity = 1, .fptr = nif_notify_cancel, .flags = 0 }, + .{ .name = "notify_register_push", .arity = 0, .fptr = nif_notify_register_push, .flags = 0 }, + .{ .name = "take_launch_notification", .arity = 0, .fptr = nif_take_launch_notification, .flags = 0 }, + .{ .name = "storage_dir", .arity = 1, .fptr = nif_storage_dir, .flags = 0 }, + .{ .name = "storage_save_to_media_store", .arity = 2, .fptr = nif_storage_save_to_media_store, .flags = 0 }, + .{ .name = "storage_external_files_dir", .arity = 1, .fptr = nif_storage_external_files_dir, .flags = 0 }, + .{ .name = "storage_save_to_photo_library", .arity = 1, .fptr = nif_storage_save_to_photo_library, .flags = 0 }, + .{ .name = "alert_show", .arity = 3, .fptr = nif_alert_show, .flags = 0 }, + .{ .name = "action_sheet_show", .arity = 2, .fptr = nif_action_sheet_show, .flags = 0 }, + .{ .name = "toast_show", .arity = 2, .fptr = nif_toast_show, .flags = 0 }, + .{ .name = "webview_eval_js", .arity = 1, .fptr = nif_webview_eval_js, .flags = 0 }, + .{ .name = "webview_post_message", .arity = 1, .fptr = nif_webview_post_message, .flags = 0 }, + .{ .name = "webview_can_go_back", .arity = 0, .fptr = nif_webview_can_go_back, .flags = 0 }, + .{ .name = "webview_go_back", .arity = 0, .fptr = nif_webview_go_back, .flags = 0 }, + .{ .name = "register_component", .arity = 1, .fptr = nif_register_component, .flags = 0 }, + .{ .name = "deregister_component", .arity = 1, .fptr = nif_deregister_component, .flags = 0 }, + .{ .name = "background_keep_alive", .arity = 0, .fptr = nif_background_keep_alive, .flags = 0 }, + .{ .name = "background_stop", .arity = 0, .fptr = nif_background_stop, .flags = 0 }, + // Mob.Device — lifecycle events + queries (Android stubs except dispatcher set). + .{ .name = "device_set_dispatcher", .arity = 1, .fptr = nif_device_set_dispatcher, .flags = 0 }, + .{ .name = "device_battery_state", .arity = 0, .fptr = nif_device_battery_state, .flags = 0 }, + .{ .name = "device_thermal_state", .arity = 0, .fptr = nif_device_thermal_state, .flags = 0 }, + .{ .name = "device_low_power_mode", .arity = 0, .fptr = nif_device_low_power_mode, .flags = 0 }, + .{ .name = "device_foreground", .arity = 0, .fptr = nif_device_foreground, .flags = 0 }, + .{ .name = "device_os_version", .arity = 0, .fptr = nif_device_os_version, .flags = 0 }, + .{ .name = "device_model", .arity = 0, .fptr = nif_device_model, .flags = 0 }, +}; + +var mob_nif_entry: erts.ErlNifEntry = .{ + .major = erts.ERL_NIF_MAJOR_VERSION, + .minor = erts.ERL_NIF_MINOR_VERSION, + .name = "mob_nif", + .num_of_funcs = nif_funcs.len, + .funcs = &nif_funcs, + .load = nifLoad, + .reload = null, + .upgrade = null, + .unload = null, + .vm_variant = erts.ERL_NIF_VM_VARIANT, + .options = 1, // enable dirty-NIF support — matches what ERL_NIF_INIT emits. + .sizeof_ErlNifResourceTypeInit = erts.SIZEOF_ErlNifResourceTypeInit, + .min_erts = erts.ERL_NIF_MIN_ERTS_VERSION, +}; + +/// `mob_nif_nif_init` — the symbol the BEAM looks up via the static NIF +/// table to find this NIF's `ErlNifEntry`. driver_tab_android.zig already +/// extern-declares it. STATIC_ERLANG_NIF + ERL_NIF_INIT_NAME(mob_nif) in +/// the C header would have expanded to the same symbol. +pub export fn mob_nif_nif_init() callconv(.c) *erts.ErlNifEntry { + return &mob_nif_entry; +} diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig index f24f7f27..dd375efc 100644 --- a/android/jni/mob_zig.zig +++ b/android/jni/mob_zig.zig @@ -93,6 +93,8 @@ pub fn nowNs() i64 { // equivalent and skips the link-time guard. pub extern fn malloc(size: usize) ?*anyopaque; pub extern fn free(ptr: ?*anyopaque) void; +pub extern fn strlen(s: [*:0]const u8) usize; +pub extern fn strdup(s: [*:0]const u8) ?[*:0]u8; pub const _IONBF: c_int = 2; From 4d9725c3b3d51374e8ca903523f21b6a61e6bc0c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 14:26:39 -0600 Subject: [PATCH 030/254] build_system_migration: Phase 6b iter 3d (finale) logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mob_nif.c is gone. The full mob_nif port is complete: 4457 lines of Zig across mob_nif.zig + mob_erts.zig + mob_zig.zig + mob_beam.zig. Documents what landed in iter 3d (the bridge bootstrap helpers, the remaining ~50 NIFs, the async-result dispatchers, the ErlNifEntry hand-built struct that replaces the ERL_NIF_INIT macro, and the nif_load callback) and the FFI extensions that landed in mob_erts.zig + mob_zig.zig along the way. The only remaining work on Phase 6b is the end-to-end smoke deploy against a real Android emulator — a verification step better kept as a separate commit so the test boundary is explicit. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 77 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index 134e7169..da2139ef 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1279,7 +1279,76 @@ something useful even if the total project pauses. senders + mob_handle_back + 23 nif_*). 702/702 mob tests + credo strict clean + clang-format clean. mob_nif.c lost ~666 lines net. - - iter 3d (planned): remaining feature NIFs — storage, WebView, - alert/action_sheet/toast, native view components, background - lifecycle, Mob.Device. Moves the `ErlNifFunc nif_funcs[]` - table to Zig. mob_nif.c deleted. + - iter 3d (finale — mob_nif.c deleted, all-Zig NIF surface): + the multi-iter port is done. mob_nif.c is gone after starting + iter 3a at 2570 lines. The final Android native code surface + is 4457 lines: 2932 in mob_nif.zig, 281 in mob_erts.zig, 569 + in mob_zig.zig, 675 in mob_beam.zig. The only `.c` file + remaining in the Android native build is the per-app + `beam_jni.c` stub (JNI entrypoints + `g_jvm`/`g_activity` + globals), kept as C so app authors don't need Zig to read + their own JNI bridge. + + Moved in this iter: + + • Bridge bootstrap (`_mob_ui_cache_class_impl`, + `mob_set_startup_phase`, `mob_set_startup_error`, + `_mob_bridge_init_activity`) — exported with C ABI so + mob_beam.zig and beam_jni.c keep calling them unchanged. + • All remaining feature NIFs: color_scheme, exit_app, + safe_area, haptic, clipboard ×2, open_url, share_text, + biometric_authenticate, request_permission, location ×3, + camera ×4, photos_pick, files_pick, audio ×5, motion ×2, + scanner, notify ×3, storage ×4, alert/action_sheet/toast, + webview ×4, background ×2, device ×7 (dispatcher_set + + 6 stubs). + • Async result dispatchers (called from Kotlin via JNI): + `mob_deliver_atom2/atom3/location/motion/webview_message/ + webview_blocked/file_result/push_token/notification/ + alert_action`, plus the legacy `mob_nif_deliver_json` + no-op. + • Launch notification global + writer + take NIF. + • Mob.Device dispatcher pid + `mob_send_color_scheme_changed`. + • The `ErlNifFunc nif_funcs[]` table (75 entries; dirty-job + flags preserved on the four CPU-bound NIFs). + • `nif_load` BEAM callback — caches all ~45 method IDs and + creates the launch-notification mutex. Replaces the C-side + `CACHE`/`CACHE_OPT` macros with `cacheRequired` / + `cacheOptional` Zig inlines. + • Hand-built `ErlNifEntry` struct + `mob_nif_nif_init` — + replaces the `ERL_NIF_INIT(mob_nif, …)` C macro with a + plain Zig struct literal + `export fn` returning a + pointer to it. driver_tab_android.zig already extern- + declared the symbol from iter 3a, so the static-NIF link + path keeps working unchanged. + + FFI extensions: + + • mob_erts.zig: `ErlNifEntry` extern struct + the four + callback function-pointer typedefs (Load/Reload/Upgrade/ + Unload) + ERL_NIF_{MAJOR,MINOR}_VERSION constants + + ERL_NIF_MIN_ERTS_VERSION + ERL_NIF_VM_VARIANT + + ERL_NIF_DIRTY_JOB_{CPU,IO}_BOUND flag constants + + SIZEOF_ErlNifResourceTypeInit (the ABI-compat gate). + • mob_zig.zig: `strlen` + `strdup` extern decls (used by + deliver_* helpers + the launch-notification strdup-and- + store path). + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with + 124 exported symbols. Every reference beam_jni.c needs + (`mob_send_*`, `mob_deliver_*`, `mob_handle_back`, + `mob_set_launch_notification`, `mob_init_bridge`, + `mob_ui_cache_class`, `mob_start_beam`, + `mob_send_color_scheme_changed`) resolves at link time. The + `mob_nif_nif_init` symbol the driver_tab references is now + exported from Zig. 702/702 mob tests + 224/224 mob_new tests + + credo strict clean on both. mob_new template drops + mob_nif.c from its source list — the only remaining `.c` is + `beam_jni.c`. + + The full Android end-to-end smoke deploy (mix mob.deploy + --native against a connected emulator) is the next thing to + run — it bundles best as its own verification commit so the + test path is explicit about exercising the all-Zig finale. + Once that's green, Phase 6b is complete. From 86d5560c81ab8d8e2aff174a7c95de22f49a6d0f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 14:40:16 -0600 Subject: [PATCH 031/254] nif_future: mark item #4 (iOS Python wheel copy) as FIXED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to mob_dev `dbd6a48` — `copy_project_python_wheels/1` generalised + wired into both `maybe_setup_pythonx_sim/5` and `maybe_setup_pythonx_device/5` so iOS bundles pick up `priv/python_wheels/*` the same way Android already did. The original report body is kept for context (under "Original report") since the fix path it suggested — landing in build_device.zig — became stale after Phase 2 iter 13b/13c moved iOS build glue out of shell/Zig into Mix. Co-Authored-By: Claude Opus 4.7 --- nif_future.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/nif_future.md b/nif_future.md index 47661a3f..fcd16dcd 100644 --- a/nif_future.md +++ b/nif_future.md @@ -136,3 +136,93 @@ the kind of thing the build rebuild can address as part of "what gets shipped to the device" rather than as point patches in app code or per-consumer monkey-patches. Worth keeping them in mind as test cases when the new build pipeline reaches the deploy step. + +--- + +## 4. iOS device build skips `copy_project_python_wheels` (verified 2026-05-11, **FIXED 2026-05-11**) + +**Resolution**: `mob_dev` `lib/mob_dev/native_build.ex` — +`copy_project_python_wheels/1` generalised (param renamed +`assets_root` → `python_root`, docstring covers both platforms) and +wired into both `maybe_setup_pythonx_sim/5` (right after the +lib-dynload `copy_dir!`) and `maybe_setup_pythonx_device/5` (right +after the lib-dynload `cp_r!`). Both call sites pass +`/python` as the root — same `lib/python3.13/site-packages/` +suffix as Android, so the helper works unchanged. The historical +notes below are kept for context. + +--- + +### Original report + + +**Refines item 3 above** — the cryptography cross-compile spike isn't +actually required. RNS gracefully falls back to its internal pure- +Python crypto provider when `cryptography` isn't importable (see +`RNS/Cryptography/Provider.py`), and `lxmf` is pure-Python on top of +RNS. So the wheel set we actually need on iOS is just `rns + lxmf` +(both pure-Python, ~few MB total), plus `pyserial` + `pycparser` if +any project uses them. + +**The gap**: Android's `copy_python_assets/1` already does +`copy_project_python_wheels(assets_root)` after dropping stdlib + +lib-dynload into the APK. iOS *simulator's* `ios/build.sh` (the +project-local one mob_dev does NOT regenerate) was patched in the +Pigeon session to do the same into `/python/lib/python3.13/ +site-packages/`. iOS *device's* auto-generated `build_device.sh` +(produced by `MobDev.NativeBuild.generate_build_device_sh/2`) bundles +Python.framework + stdlib + lib-dynload but never copies +`priv/python_wheels/*` in. Result: device boots, hits `import RNS`, +crashes with `ModuleNotFoundError: No module named 'RNS'`, app +appears stuck on the launch spinner. + +**Workaround for manual dev cycles**: after a build, find the staged +`Pigeon.app` (under `$TMPDIR/mob_ios_device_*`), copy +`priv/python_wheels/{rns,lxmf,pyserial,pycparser}/.` into +`Pigeon.app/otp/python/lib/python3.13/site-packages/`, re-sign with +the in-build `mob_device.entitlements` file, then `xcrun devicectl +device install app`. Verified working on iPhone SE 3rd gen +(00008110-001E1C3A34F8401E) on 2026-05-11. + +**What the build rebuild should do**: add a wheel-copy step to the +iOS device path mirroring Android's. The cleanest spot is right +after the `cp -R "$PYTHON_LIB_DYNLOAD" "$OTP_ROOT/python/lib/ +python3.13/lib-dynload"` line in the build_device.sh template (or +its Zig successor — `ios/build_device.zig` is where this naturally +lives after Phase 2 iter 12). Same shape as Android, same wheel +source (`priv/python_wheels//`), same destination layout +(`/python/lib/python3.13/site-packages/`). + +--- + +## 5. iOS device default relay host (verified 2026-05-11) + +**Symptom**: Pigeon (or any mob app using a Mac-based dev relay) on +physical iOS gets `[Errno 61] Connection refused` for the relay +TCPInterface. `127.0.0.1` resolves to the *phone's* loopback, not the +developer's Mac — different from the iOS simulator (which shares the +host network stack via XPC) and Android emulator (which has the +`10.0.2.2` host-loopback alias). + +**Where the bad default came from**: `Pigeon.App.on_start/0` +hard-codes a platform-aware default of `127.0.0.1` for iOS and +`10.0.2.2` for Android via `Pigeon.PythonPaths.detect/1`. Both are +*simulator/emulator* defaults; neither works on real hardware. + +**Workaround for now**: rely on AutoInterface multicast over LAN +(verified working — iPhone SE 3rd gen reached the bridge via shared +Wi-Fi). Set `PIGEON_RELAY_HOST` to the Mac's actual LAN IP when +explicit relay routing is needed. + +**What's needed**: detect "physical device" vs "simulator/emulator" +at build time (or compute the Mac's LAN IP and stamp it into the +build env) so the in-app default is right by default. The detection +is already in `Pigeon.PythonPaths.detect/1` (returns `:ios` for +both sim and device today — that's the bug); split into `:ios_sim` +vs `:ios_device` or surface the Mac's LAN IP via a build-time env +var the way `MOB_IOS_TEAM_ID` etc. flow today. + +Both items 4 and 5 are small mob_dev template changes. Either land +them as point fixes in build_device.sh / build_device.zig templates, +or fold them into Phase 2 iter 12d's bundle-assembly + provisioning +move into Mix proper. From 4052c7f7ce55498d0bf2b4b36f7378eb0f6e1275 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 15:08:47 -0600 Subject: [PATCH 032/254] nif: fix two Android runtime bugs caught by Phase 6b smoke deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end deploy of a freshly-generated mob_smoke_6b project against emulator-5556 (aarch64-android.24) surfaced two latent bugs in the iter 3c/3d port that pure compile-time checks (`zig build-obj`) and the test suite couldn't catch: 1. mob_erts.zig: `enif_make_int64` / `enif_make_uint64` failed `dlopen` at app launch with `cannot locate symbol "enif_make_int64"`. OTP's `erl_nif_api_funcs.h` does #if SIZEOF_LONG == 8 # define enif_make_int64 enif_make_long # define enif_make_uint64 enif_make_ulong #endif so on aarch64-android (LP64) the real libbeam.a symbols are `enif_make_long` / `enif_make_ulong` — `enif_make_int64` is just a preprocessor alias that doesn't exist as a linker symbol. Zig doesn't run the C preprocessor, so the bare `extern fn enif_make_int64` looked for a literal symbol that wasn't there. Switched to `@extern` with comptime symbol-name selection: 64-bit picks `enif_make_long`, 32-bit picks the real `enif_make_int64` (which is a distinct symbol when `long` is 4 bytes). User-facing `enif_make_int64` / `enif_make_uint64` keep their natural signatures. 2. mob_nif.zig: `pidToJlong` / `pidFromLong` `@bitCast` failed at compile time on armeabi-v7a: @bitCast size mismatch: destination type 'c_ulong' has 32 bits but source type 'i64' has 64 bits On 32-bit ARM, ERL_NIF_TERM is `c_ulong` = u32, but `jlong` is always i64. The C original used `memcpy(min(sizeof(...)))` to handle both widths. The Zig version assumed 64-bit. Added a comptime `@sizeOf` branch: when ERL_NIF_TERM matches jlong width, `@bitCast` is a true reinterpret; otherwise we zero-extend on the way out and truncate on the way back. The high 32 bits of the jlong carry no information on 32-bit anyway — they just round-trip whatever Kotlin saw. Verified: full `mix mob.deploy --native --device emulator-5556` on a fresh mob_smoke_6b project now produces working APKs for both arm64-v8a and armeabi-v7a; the BEAM boots successfully and `nif_load` reports "Mob NIF loaded (Compose backend)" in logcat. The cold-start race fix from iter 2's mob_beam.zig also fires correctly (`waited 1750 ms for window focus`). The remaining build template bugs (`.pic = true` on Zig modules, plus the `addLink`→`addExqliteLink` ordering edge) are companion fixes in mob_new — separate commit there. Co-Authored-By: Claude Opus 4.7 --- android/jni/mob_erts.zig | 36 ++++++++++++++++++++++++++++++++++-- android/jni/mob_nif.zig | 26 +++++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index 8300fa20..fbb371ed 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -151,8 +151,40 @@ pub extern fn enif_alloc_binary(size: usize, bin: *ErlNifBinary) c_int; // 64-bit integer constructors (iter 3c). Used by the throttled gesture/ // scroll/drag/pinch senders for monotonic timestamps and sequence numbers. -pub extern fn enif_make_int64(env: ?*ErlNifEnv, i: i64) ERL_NIF_TERM; -pub extern fn enif_make_uint64(env: ?*ErlNifEnv, i: u64) ERL_NIF_TERM; +// +// Symbol-name twist: OTP's `erl_nif_api_funcs.h` does +// +// #if SIZEOF_LONG == 8 +// # define enif_make_int64 enif_make_long +// # define enif_make_uint64 enif_make_ulong +// #endif +// +// On aarch64-android (LP64 — `long` is 8 bytes) the real symbols in +// libbeam.a are `enif_make_long` / `enif_make_ulong`; `enif_make_int64` +// is just a preprocessor alias. Zig doesn't run the C preprocessor, so +// `extern fn enif_make_int64` would look for a literal symbol that +// doesn't exist on 64-bit and dlopen would fail at app launch with +// `cannot locate symbol "enif_make_int64"`. +// +// On armeabi-v7a (ILP32 — `long` is 4 bytes) the alias doesn't fire and +// `enif_make_int64` is a real symbol. We pick the right linker name at +// comptime via `@extern`. +const enif_make_int64_fn = @extern( + *const fn (?*ErlNifEnv, i64) callconv(.c) ERL_NIF_TERM, + .{ .name = if (@sizeOf(c_long) == 8) "enif_make_long" else "enif_make_int64" }, +); +const enif_make_uint64_fn = @extern( + *const fn (?*ErlNifEnv, u64) callconv(.c) ERL_NIF_TERM, + .{ .name = if (@sizeOf(c_long) == 8) "enif_make_ulong" else "enif_make_uint64" }, +); + +pub inline fn enif_make_int64(env: ?*ErlNifEnv, i: i64) ERL_NIF_TERM { + return enif_make_int64_fn(env, i); +} + +pub inline fn enif_make_uint64(env: ?*ErlNifEnv, i: u64) ERL_NIF_TERM { + return enif_make_uint64_fn(env, i); +} // Term-env hop (iter 3c). enif_send delivers a message to a pid; the // `msg_env` must be a "process-independent" env allocated via diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index f9c05f80..72b87753 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -1536,15 +1536,31 @@ inline fn freeCString(p: ?[*:0]u8) void { /// Pack an ErlNifPid into a jlong for the JNI-side delivery handle. Kotlin /// hands it back unchanged when it calls one of the mob_deliver_* hooks; -/// we round-trip via `pidFromLong`. ErlNifPid is `{ ERL_NIF_TERM pid; }` -/// which is `c_ulong` on aarch64 — same size as jlong — so a bitcast is -/// equivalent to the C `memcpy(&jpid, &pid, sizeof(...))` pattern. +/// we round-trip via `pidFromLong`. +/// +/// Size mismatch handling: on aarch64 ERL_NIF_TERM is c_ulong = u64, +/// same width as jlong (i64), so a @bitCast is a true reinterpret. On +/// armeabi-v7a (32-bit ARM) ERL_NIF_TERM is u32 but jlong is still i64, +/// so we zero-extend on the way out and truncate on the way back. This +/// mirrors the C original's `memcpy(min(sizeof(ErlNifPid), sizeof(jlong)))` +/// dance — the high 32 bits of the jlong carry no information on 32-bit +/// ARM, they just round-trip whatever Kotlin saw. inline fn pidToJlong(pid: erts.ErlNifPid) jni.JLong { - return @bitCast(pid.pid); + if (@sizeOf(erts.ERL_NIF_TERM) == @sizeOf(jni.JLong)) { + return @bitCast(pid.pid); + } + // 32-bit ARM: zero-extend the u32 pid into the low 32 bits of i64. + return @intCast(pid.pid); } inline fn pidFromLong(jpid: jni.JLong) erts.ErlNifPid { - return .{ .pid = @bitCast(jpid) }; + if (@sizeOf(erts.ERL_NIF_TERM) == @sizeOf(jni.JLong)) { + return .{ .pid = @bitCast(jpid) }; + } + // 32-bit ARM: take the low 32 bits of the jlong. The high bits are + // whatever Kotlin's been passing around — discard them. + const low: u32 = @truncate(@as(u64, @bitCast(jpid))); + return .{ .pid = low }; } /// Call `MobBridge.(pid_long, arg)` — the standard shape for From 8760bb3af74656c99b9456d174253e8097ff6bf0 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 15:10:20 -0600 Subject: [PATCH 033/254] build_system_migration: Phase 6b verified end-to-end (Phase 6b COMPLETE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 6b iter 3d smoke deploy ran green against emulator-5556 on 2026-05-11. Documents the four latent bugs caught at the deploy boundary (PIC, addLink ordering, enif_make_int64 alias, pid 32/64-bit width) — three were caught between compile-OK and link-OK; one was caught at app launch (dlopen). Captures the "pure-compile standalone check is necessary but not sufficient" lesson for future iters that touch the Zig native build. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/build_system_migration.md b/build_system_migration.md index da2139ef..6cd04b07 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -1352,3 +1352,63 @@ something useful even if the total project pauses. run — it bundles best as its own verification commit so the test path is explicit about exercising the all-Zig finale. Once that's green, Phase 6b is complete. + + - iter 3d verification (smoke deploy, 2026-05-11): + **Phase 6b complete.** End-to-end deploy of a freshly- + generated `mob_smoke_6b` project against emulator-5556 + (aarch64-android.24) succeeded: full Zig pipeline cross- + compiled for both arm64-v8a and armeabi-v7a, NDK clang linked + cleanly, APK installed, OTP runtime + 382 BEAMs pushed, BEAM + booted into `Mob NIF loaded (Compose backend)`. The cold-start + race fix from iter 2's mob_beam.zig fired correctly + (`waited 1750 ms for window focus`); SELinux symlink dance + for the ERTS bins + exqlite NIF succeeded; `nif_load` cached + all required + optional method IDs. + + Four latent bugs surfaced at the boundary and were fixed + before the green run: + + * `mob_new` build template — Zig module `.pic = true` + missing on `createModule`. `mob_beam.zig`'s `default_flags` + comptime array of pointers to string literals emitted + R_AARCH64_ABS64 relocations against local symbols, which + ld.lld refused in a shared library. The pure-compile + `zig build-obj` standalone check didn't catch this — the + relocations are only validated at link time. + * `mob_new` build template — `addLink` produced the cp step + that installs `lib.so` into jniLibs/ but didn't + return it. `addExqliteLink` referenced the installed path + as a plain string arg (not a LazyPath), so the two link + steps raced and exqlite's clang errored out with `no + such file`. Fix: `addLink` returns the cp step; + `addExqliteLink.depends_on` carries the edge. + * `mob_erts.zig` — bare `extern fn enif_make_int64` / + `enif_make_uint64` failed dlopen with `cannot locate + symbol "enif_make_int64"`. OTP's `erl_nif_api_funcs.h` + does `#define enif_make_int64 enif_make_long` when + `SIZEOF_LONG == 8`; on aarch64-android (LP64) the real + libbeam.a symbol is `enif_make_long`. Zig doesn't run + the C preprocessor — fixed by switching to `@extern` + with comptime symbol-name selection (`enif_make_long` + on 64-bit, `enif_make_int64` on 32-bit where the alias + doesn't fire). + * `mob_nif.zig` — `pidToJlong` / `pidFromLong`'s `@bitCast` + failed to compile on armeabi-v7a: ERL_NIF_TERM is u32 + there but jlong is always i64. Fixed with a comptime + `@sizeOf` branch: bitcast on 64-bit, zero-extend/truncate + on 32-bit. Matches the C original's `memcpy(min(sizeof))` + dance. + + Lesson: **the pure-compile standalone check pattern caught + every compile error but no link error and no runtime error.** + For future iters touching the Zig native build, plan on the + end-to-end smoke deploy as a separate verification step — + object compile and test-suite pass are necessary but not + sufficient. The mob_new template's `mix test --only lint` + pipeline could grow a "zig build emit-relocatable" step that + actually links, which would have caught the PIC bug + pre-merge; queued as a follow-up. + + Bugs fixed in: mob `50f87bb` (mob_erts.zig + mob_nif.zig), + mob_new `481bcd5` (build.zig.eex template). Smoke-tested + project preserved at `/tmp/mob_smoke_6b/` for inspection. From 7df0a73df06c1d76d9a07e0ae6726f8ee2827db1 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 11 May 2026 18:48:51 -0600 Subject: [PATCH 034/254] =?UTF-8?q?build=5Fsystem=5Fmigration:=20Phase=206?= =?UTF-8?q?c=20COMPLETE=20=E2=80=94=20release=20scripts=20=E2=86=92=20MobD?= =?UTF-8?q?ev.Release.*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 8 release shell scripts (openssl x2, crypto-nif, xcompile x3, tarball x4, publish) now have tested Elixir replacements under MobDev.Release.* in mob_dev. Every gh failure classifies into typed error categories so the release pipeline distinguishes "GitHub outage" from "expired auth" from "our bug" at the call site. The Zig-build option was reconsidered: orchestration is the wrong job for Zig's compile-graph model, but compile orchestration (zig cc) stays in scope for the C-builder deferral in iter 13d. Co-Authored-By: Claude Opus 4.7 --- build_system_migration.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/build_system_migration.md b/build_system_migration.md index 6cd04b07..ba146661 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -224,10 +224,20 @@ others. - iOS Objective-C stays as-is — ARC handles memory; ObjC's Cocoa idiom is right - Touches: mob primarily -**6c — OTP rebuild scripts → `build.zig`:** -- `scripts/release/openssl/build_crypto_static_*.sh` → Zig build -- `scripts/release/xcompile_*.sh` → Zig build -- `scripts/release/tarball_*.sh` → Zig build (or stay shell — these are simpler) +**6c — OTP release scripts → tested Elixir under `MobDev.Release.*`:** ✓ complete (2026-05-11) +- `scripts/release/openssl/build_crypto_static_*.sh` → `MobDev.Release.OpenSSL` + + `MobDev.Release.OpenSSL.CryptoNif` +- `scripts/release/xcompile_*.sh` → `MobDev.Release.OTP` +- `scripts/release/tarball_*.sh` → `MobDev.Release.Tarball` +- `scripts/release/publish.sh` → `MobDev.Release.Publish` +- All routed through `MobDev.Release.Shell` behaviour for mockable I/O; + every `gh` failure classified into typed error categories + (`:auth_required`, `:infra_unreachable`, `:precondition_failed`, + `:cmd_failed`) so the release pipeline can distinguish "GitHub + outage" from "expired auth" from "our bug" at the call site. The + Zig-build option was reconsidered: orchestration is the wrong job + for Zig's compile-graph model, but compile orchestration (zig cc) + stays in scope for the C-builder deferral in iter 13d. - Touches: mob_dev --- From 23500091a0adfa294a6b4424a0c588fdf4aed344 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 13:32:30 -0600 Subject: [PATCH 035/254] Clean up Elixir 1.20-rc.4 warnings + fix sigil's per-call-site is_list dead arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three warnings surfaced by Elixir 1.20-rc.4 / OTP 28 in mob's compile output during a downstream test_migration build: lib/mob/event/target.ex GenServer.whereis/1's spec is `pid | {atom, node} | nil` BUT the body narrows the result to `pid | nil` when the input is a `{:via, _, _}` 3-tuple (the registry callbacks always return pid or :undefined). The `{_name, _node} = remote` arm was a historical attempt to handle the {name, node} variant — only reachable for `{name, node}` inputs, which this clause never receives. Drop the dead arm; expand the docstring breadcrumb so a future contributor doesn't re-add it. lib/mob/device.ex `maybe_set_dispatcher/0` only ever returns `:ok` or `{:error, :nif_not_loaded}`. The `{:error, reason}` catch-all arm in `init/1` was unreachable. lib/mix/tasks/erlfmt.ex `:erlfmt.format_file/2` static reference warned because erlfmt is `only: :dev, runtime: false` in mob's mix.exs and isn't on the path when mob is compiled as a dep of a downstream project. Switch to `apply/3` for runtime dispatch + a Code.ensure_loaded? preflight that surfaces a clear "add the dep" message if it really is missing. lib/mob/sigil.ex — the bigger fix Surfaced once per mob_new-generated screen via the `~MOB"""..."""` sigil, e.g.: warning: the following clause will never match: list when is_list(list) -> because it attempts to match on the result of: nav_button("Text Input", :open_text) which has type: dynamic(%{ ... type: :button ... }) Root cause: `build_children_ast/2` generated an inline `case` per `{expr}` child: case unquote(expr) do list when is_list(list) -> list node -> [node] end When the expression has a static return shape (a single map — the common case for screen-internal helper functions), the type checker correctly narrows and reports the `is_list/1` arm as unreachable. But the multi-shape tolerance IS the whole point of `{expr}` children — users write `Enum.map(items, &row/1)` and expect list-flattening. Fix: route through a public helper `Mob.Sigil.wrap_child/1` with two function-head clauses. The compiler can't narrow across function calls the same way it does within a `case`, so the per-call-site warning goes away while the runtime behaviour (single-node wrap vs list passthrough) is identical. Every mob app generated by mob_new gets the warnings cleared the next time it recompiles against this version of mob. mix test: 27 doctests, 702 tests, 0 failures Co-Authored-By: Claude Opus 4.7 --- lib/mix/tasks/erlfmt.ex | 14 +++++++++++++- lib/mob/device.ex | 6 +++--- lib/mob/event/target.ex | 7 ++++++- lib/mob/sigil.ex | 28 ++++++++++++++++++++++------ 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/lib/mix/tasks/erlfmt.ex b/lib/mix/tasks/erlfmt.ex index d96e9abe..ea2fd159 100644 --- a/lib/mix/tasks/erlfmt.ex +++ b/lib/mix/tasks/erlfmt.ex @@ -34,13 +34,25 @@ defmodule Mix.Tasks.Erlfmt do if paths == [], do: Mix.raise("mix erlfmt requires at least one path") + # erlfmt is `only: :dev, runtime: false` in mob's mix.exs, so when + # mob is compiled as a dep of a downstream project (without erlfmt + # in *their* deps), the static reference would warn. Resolve at + # runtime via apply so the compiler doesn't complain — and surface + # a clean error if the dep really isn't on the path. + unless Code.ensure_loaded?(:erlfmt) do + Mix.raise( + "mix erlfmt requires the `:erlfmt` dep — add `{:erlfmt, \"~> 1.8\", " <> + "only: :dev, runtime: false}` to your project's mix.exs and rerun `mix deps.get`." + ) + end + Application.ensure_all_started(:erlfmt) files = Enum.flat_map(paths, &collect_erl_files/1) {ok_count, changed} = Enum.reduce(files, {0, []}, fn file, {ok, changed} -> - case :erlfmt.format_file(String.to_charlist(file), [:return]) do + case apply(:erlfmt, :format_file, [String.to_charlist(file), [:return]]) do {:ok, formatted, _warnings} -> original = File.read!(file) # erlfmt returns iodata that may include codepoints > 255 (e.g. diff --git a/lib/mob/device.ex b/lib/mob/device.ex index 2e8ae41d..7711dec8 100644 --- a/lib/mob/device.ex +++ b/lib/mob/device.ex @@ -162,10 +162,10 @@ defmodule Mob.Device do {:error, :nif_not_loaded} -> # Expected when running on the host (tests, IEx without device). + # `maybe_set_dispatcher/0` only ever returns `:ok` or this + # specific `:nif_not_loaded` shape — broader `{:error, reason}` + # catch-all was unreachable and the 1.20 type checker flagged it. :ok - - {:error, reason} -> - :logger.warning("Mob.Device: NIF dispatcher not set: #{inspect(reason)}") end {:ok, %{subscribers: %{}, monitors: %{}}} diff --git a/lib/mob/event/target.ex b/lib/mob/event/target.ex index c9947601..cea31051 100644 --- a/lib/mob/event/target.ex +++ b/lib/mob/event/target.ex @@ -94,10 +94,15 @@ defmodule Mob.Event.Target do end def resolve({:via, mod, key} = via, _scope) when is_atom(mod) do + # GenServer.whereis/1 narrows to `pid | nil` for `{:via, _, _}` + # inputs (the registry callbacks normalize their result before + # returning). The historical `{_name, _node}` arm came from + # treating `GenServer.whereis/1`'s full @spec — that variant + # only fires for `{name, node}` inputs, which this clause never + # passes. case GenServer.whereis(via) do nil -> {:error, {:via_not_resolvable, mod, key}} pid when is_pid(pid) -> {:ok, pid} - {_name, _node} = remote -> {:error, {:remote_not_supported, remote}} end end diff --git a/lib/mob/sigil.ex b/lib/mob/sigil.ex index 6dbaba28..028d8261 100644 --- a/lib/mob/sigil.ex +++ b/lib/mob/sigil.ex @@ -283,12 +283,18 @@ defmodule Mob.Sigil do quoted = Code.string_to_quoted!(String.trim(expr_str), file: caller.file, line: caller.line) - quote do - case unquote(quoted) do - list when is_list(list) -> list - node -> [node] - end - end + # Emit a call to `wrap_child/1` rather than an inline `case`. + # The inline version generates a `case` per call site whose + # `is_list(list)` clause is type-narrowed to "unreachable" + # whenever the user's expression has a static-shape return + # (e.g. `nav_button("Foo", :bar)` is always a map). The + # warning is correct in isolation but the multi-shape + # tolerance is the WHOLE POINT of {expr} children — users + # can write `Enum.map(items, &row/1)` and get list-flattened + # behaviour. Dispatching via a helper hides the + # type-narrowing from the per-call-site warning while + # preserving both shapes' runtime behaviour. + quote do: Mob.Sigil.wrap_child(unquote(quoted)) node_tuple -> ast = build_ast(node_tuple, caller) @@ -298,6 +304,16 @@ defmodule Mob.Sigil do quote do: List.flatten(unquote(child_asts)) end + @doc """ + Normalizes a `{expr}` child's value to a list of UI-node maps for + the surrounding sigil. Single nodes wrap into a one-element list; + lists pass through. Public so the sigil-generated AST can call it + by FQ name; not part of the application API. + """ + @spec wrap_child(list() | map()) :: list() + def wrap_child(list) when is_list(list), do: list + def wrap_child(node), do: [node] + defp resolve_type(tag, caller) do atom = tag |> Macro.underscore() |> String.to_atom() From 8df69ca0fff84a4349546805b7755eb3e23c6847 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 15:54:52 -0600 Subject: [PATCH 036/254] mix.exs: document the known mix_unused 1.20 dep warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic warning during clean compile under Elixir 1.20-rc.4: lib/mix_unused/filter.ex:61 (`_.._` deprecation). Dep is at its latest published version (0.4.1, 2024) — no upstream fix to bump to. Note this in the deps list so the warning isn't mistaken for something we introduced. Co-Authored-By: Claude Opus 4.7 --- mix.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mix.exs b/mix.exs index 78c29568..238a6f7c 100644 --- a/mix.exs +++ b/mix.exs @@ -159,6 +159,11 @@ defmodule Mob.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false}, {:erlfmt, "~> 1.8", only: :dev, runtime: false}, + # Known Elixir 1.20-rc.4 dep warning (cosmetic, dev-only): + # lib/mix_unused/filter.ex:61 — `_.._ inside match is deprecated`. + # No upstream fix shipped yet (0.4.1 is latest, from 2024). Bump + # this version + drop this comment once mix_unused ships a 1.20-clean + # release. {:mix_unused, "~> 0.4", only: :dev, runtime: false}, {:ecto_sqlite3, "~> 0.18", only: :test} ] From a038da1dbde635a28ccd29faec872e43a2cbe1ed Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 17:38:18 -0600 Subject: [PATCH 037/254] CLAUDE.md: tests cover sigils + build helpers + NIF stub modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by the per-call-site is_list warning regression in Mob.Sigil this session — test the generated AST, not just the runtime behaviour. Same discipline whether the code runs at compile time, build time, or runtime. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a7200e60..b870b129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,30 @@ but if in doubt, ask. --- +## Tests cover everything, not just runtime code + +Every behavior in this repo gets a test — including build helpers +and any CLI surface that lives here (less common in `mob` than in +`mob_dev`, but the discipline is the same). Runtime modules like +`Mob.Screen`, `Mob.Renderer`, `Mob.Sigil` get the obvious +unit/integration coverage. **Beyond runtime:** + +- NIF stub modules (`mob_nif.erl`, when it gains more surface): + pure helpers extracted from the C/Zig side get Elixir tests. +- Sigil compile-time AST transforms: test the generated AST, + not just runtime behavior. This caught the + `Mob.Sigil.wrap_child/1` per-call-site warning regression this + session. +- Build-time helpers (driver_tab generators, native build glue + when it lives here): same rule. + +The goal is **find bugs in CI before users hit them.** A bug found +by a test takes minutes to fix; one found by a user takes a +bug-report-to-fix cycle plus damage to confidence. When you touch +something untested, either add coverage or note it as a follow-up. + +--- + ## Pre-commit checklist Before committing changes, run **all** in this order: From eb0a993a3ae936f218e5fa8ee1abb0790ca8d14a Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 18:12:53 -0600 Subject: [PATCH 038/254] Mob.DNS: in-process iOS DNS resolution via getaddrinfo NIF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Mob.DNS.resolve/1 + Mob.DNS.preresolve/1 to work around BEAM's inet_gethost helper being unrunnable on iOS — the sandbox forbids execve of bundled binaries, so every hostname lookup through :inet fails (Req, Finch, Mint, :httpc, gen_tcp). The NIF calls Darwin's libc getaddrinfo in-process and seeds :inet_db so subsequent BEAM lookups find the entry from the file table. Android is unaffected — mob_beam.zig ships inet_gethost as libinet_gethost.so in jniLibs/, which SELinux allows to exec. Includes guides/dns_on_ios.md covering the full why, when to call resolve / preresolve, scope (IPv4 only, one IP per host, no auto refresh), and which NIF-based HTTP libraries don't need the fix. Wires the new guide into the README documentation section, ExDoc extras, and a troubleshooting entry pointing users with the symptom to the guide. resolve_ipv4 NIF is dirty-IO-scheduled since getaddrinfo can block on the resolver for seconds. --- README.md | 1 + guides/dns_on_ios.md | 263 ++++++++++++++++++++++++++++++++++++++ guides/troubleshooting.md | 30 +++++ ios/mob_nif.m | 97 ++++++++++++++ lib/mob/dns.ex | 181 ++++++++++++++++++++++++++ mix.exs | 1 + src/mob_nif.erl | 7 +- test/mob/dns_test.exs | 143 +++++++++++++++++++++ 8 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 guides/dns_on_ios.md create mode 100644 lib/mob/dns.ex create mode 100644 test/mob/dns_test.exs diff --git a/README.md b/README.md index 2bf08d87..534c8b77 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Full documentation at [hexdocs.pm/mob](https://hexdocs.pm/mob), including: - [Theming](https://hexdocs.pm/mob/theming.html) - [Navigation](https://hexdocs.pm/mob/navigation.html) - [Device Capabilities](https://hexdocs.pm/mob/device_capabilities.html) +- [DNS on iOS](https://hexdocs.pm/mob/dns_on_ios.html) — required reading if your app makes HTTPS calls; one-line fix for a non-obvious iOS-only failure mode - [Testing](https://hexdocs.pm/mob/testing.html) ## License diff --git a/guides/dns_on_ios.md b/guides/dns_on_ios.md new file mode 100644 index 00000000..0279eea0 --- /dev/null +++ b/guides/dns_on_ios.md @@ -0,0 +1,263 @@ +# DNS on iOS — Why Req / Finch / Mint Fail Until You Call `Mob.DNS.resolve/1` + +If you're running a mob app on iOS and you call out to an HTTPS endpoint +by hostname — `Req.get!("https://api.example.com/...")` — the request +fails. The same code works on macOS, Linux, the iOS simulator, Android, +and physical Android. **Only the iOS device sees the failure**, and the +error is usually some flavour of "nxdomain" or "lookup failed." + +This document explains why that happens and how to make your app's HTTP +calls work on iOS with one extra line at startup. + +--- + +## TL;DR + +```elixir +# Once, before your first HTTP call (typically in your app's on_start/0): +Mob.DNS.preresolve([ + "api.example.com", + "auth.example.com" +]) + +# Now Req / Finch / Mint / HTTPoison / Tesla all work normally: +Req.get!("https://api.example.com/things") +``` + +`Mob.DNS.resolve/1` is idempotent and cheap. You can also use the bulk +form `preresolve/1` for a fixed list of backends, or call `resolve/1` +lazily right before the first request to a given host. + +--- + +## Why this exists + +BEAM resolves hostnames the same way it always has: it spawns an +external helper called `inet_gethost` — a small port program shipped +with OTP — and pipes hostname requests to it. The helper calls libc +`getaddrinfo` on your behalf and pipes the result back. The reason it's +out-of-process is historical (the BEAM didn't always trust libc to be +non-blocking, and `getaddrinfo` can block for seconds on slow +networks). + +On macOS, Linux, Windows, and Android this works fine. + +**On iOS it doesn't.** iOS sandboxes apps and forbids `execve` of any +binary the app didn't get a special pass for. There is no equivalent of +Android's "ship the helper as a `lib*.so` in `jniLibs/` and the SELinux +policy will let you `execve` it" escape hatch. When BEAM tries to spawn +`inet_gethost`, the kernel refuses. From the app's perspective, every +hostname lookup fails immediately. + +Everything that resolves hostnames through `:inet` is affected: + +- Req +- Finch +- Mint +- HTTPoison +- Tesla (via any of the above adapters) +- `:httpc` (the built-in OTP client) +- `gen_tcp:connect/3,4` when given a hostname + +Anything that resolves *outside* of `:inet` is fine — see "What this +does NOT affect" below. + +--- + +## How `Mob.DNS` works around it + +iOS doesn't block calling libc functions in-process — only `execve`. +`Mob.DNS` calls Darwin's `getaddrinfo` directly via a NIF, then seeds +the result into `:inet_db` (BEAM's in-process host table) so subsequent +`:inet.getaddr/2` calls find it from the file table without ever +spawning anything. + +The NIF does three things: + +1. Calls `getaddrinfo(host, NULL, &hints, &result)` with `hints.ai_family = AF_INET`. +2. Walks the result chain for the first IPv4 address. +3. Returns `{:ok, {a, b, c, d}}` or `{:error, reason}`. + +The Elixir wrapper then: + +1. Calls `:inet_db.add_host(ip, [host])` to seed the file table. +2. Calls `:inet_db.set_lookup([:file | other])` to put `:file` at the + front of BEAM's lookup chain (so seeded entries win over the broken + `:native` path). + +Both operations are idempotent. Calling `resolve/1` for the same host +twice is harmless. + +--- + +## What this does NOT affect + +If a NIF resolves hostnames itself — by calling libc `getaddrinfo` +directly inside its own C/Zig/Rust code — it doesn't go through BEAM's +`:inet` layer and so doesn't need (or benefit from) this fix. It +already works on iOS. + +Examples of NIFs that already do their own DNS: + +- **`crypto`** and **`ssl`** don't do DNS at all; they're handed an + already-connected socket. +- **`reticulum_nif`** (Pigeon's transport NIF) calls `getaddrinfo` + inside Reticulum's network stack. Pigeon transports work on iOS + without `Mob.DNS`. +- Most Rust NIFs using `tokio`/`hyper` (e.g. `Reqwest`-backed clients) + do their own DNS via libc. + +If you're not sure whether a particular library needs `Mob.DNS`, the +quick check is: does it eventually call `:inet.getaddr/2`, +`:gen_tcp.connect/3,4`, or `:ssl.connect/3,4` with a hostname (binary +or charlist)? If yes, it goes through BEAM's `:inet` layer and needs +`Mob.DNS`. If it shells out to a NIF that does its own networking, it +doesn't. + +--- + +## Android is unaffected — here's why + +The exact same `inet_gethost` mechanism *would* be blocked on Android +by default — SELinux policy refuses `execute_no_trans` on binaries in +the app's data directory. But Android has a documented escape hatch: +binaries packaged as `lib.so` inside `jniLibs//` get the +`apk_data_file` SELinux label, which *does* allow execution. + +`mob_beam.zig` (the Android BEAM launcher) ships the OTP helpers +(`inet_gethost`, `erl_child_setup`, `epmd`) as `lib*.so` files in +`jniLibs/arm64-v8a/`, then symlinks `BINDIR/` → +`/lib.so` before calling `erl_start`. From +BEAM's perspective, the helpers live exactly where it expects them and +are executable. DNS works normally. + +iOS has no comparable mechanism. The `Mob.DNS` NIF is the workaround. + +--- + +## When to call `resolve` / `preresolve` + +**At app startup, for known-fixed backends.** This is the simplest +pattern — list every backend your app talks to and resolve them once +in `on_start/0`: + +```elixir +def on_start do + Mob.Dist.ensure_started(...) + + Mob.DNS.preresolve([ + "api.example.com", + "auth.example.com", + "analytics.example.com" + ]) +end +``` + +The map returned from `preresolve/1` lets you log per-host failures +without aborting the whole startup: + +```elixir +for {host, result} <- Mob.DNS.preresolve(hosts) do + case result do + {:ok, ip} -> Logger.info("[dns] #{host} → #{:inet.ntoa(ip)}") + {:error, reason} -> Logger.warning("[dns] #{host} failed: #{inspect(reason)}") + end +end +``` + +**Lazily, right before the first request.** Useful if the set of +backends isn't known until login or some other runtime event: + +```elixir +def authenticated_request(host, path) do + Mob.DNS.resolve(host) # idempotent; fast if already resolved + Req.get!("https://#{host}#{path}") +end +``` + +`resolved?/1` lets you skip the call if you want to: + +```elixir +unless Mob.DNS.resolved?(host), do: Mob.DNS.resolve(host) +``` + +…but `resolve/1` is already cheap on the happy path (one libc call, +one map insertion), so the explicit guard is rarely worth it. + +--- + +## Limitations and caveats + +- **IPv4 only.** Most cloud endpoints serve A records and BEAM picks + the first one anyway. IPv6 (AAAA) is a follow-up — file an issue if + you need it. +- **One IP per host.** If the hostname has multiple A records, the + first one is used. There's no failover; if that IP becomes + unreachable mid-session, requests will fail until you call + `resolve/1` again. +- **No automatic refresh.** Seeded entries stay in `:inet_db` until + the BEAM exits. If your backend's IP changes (DNS round-robin, blue/ + green deploy), the cached entry will be stale until you re-resolve. + For most apps this is fine; if it isn't, set up a periodic + re-resolve task. +- **iOS only effectively.** On Android and host (Mac dev, Linux, the + iOS simulator) the NIF works but is unnecessary; BEAM's built-in + DNS path is fine. Calling `Mob.DNS.resolve/1` on those platforms is + harmless but redundant. +- **Doesn't help raw NIF networking.** See "What this does NOT + affect" above. + +--- + +## Errors `resolve/1` can return + +```elixir +{:ok, {a, b, c, d}} # success — IPv4 address +{:error, :badarg} # host arg invalid (not a charlist/binary) +{:error, :nxdomain} # no such hostname +{:error, :timeout} # resolver TRY_AGAIN +{:error, :no_address} # resolved but no IPv4 result +{:error, {:gai, code}} # raw getaddrinfo error code +{:error, :nif_not_loaded} # called off-device (host tests / IEx) +``` + +Treat `:nif_not_loaded` as "you're not on a device" — it's the signal +that returns from host BEAM where the NIF isn't compiled in. Useful in +tests; in production code on iOS you should never see it. + +--- + +## App Transport Security is a separate concern + +ATS (Apple's TLS-enforcement policy) is a different gate. If your +endpoint serves plain HTTP, or uses a self-signed cert, or uses an +older TLS version, ATS will block the connection even after DNS +succeeds. The errors look completely different (`NSURLErrorDomain +-1022` or similar), but it's worth knowing that "my request fails on +iOS" can mean DNS *or* ATS. If `Mob.DNS.resolve/1` returns `{:ok, _}` +and the request still fails with a TLS-looking error, suspect ATS +next. + +--- + +## Why the manual call instead of automatic interception + +In principle a startup hook could intercept every `:inet.getaddr` +call, resolve via NIF, and seed `:inet_db` transparently — and the +user would never have to touch `Mob.DNS` at all. We didn't go that +route because: + +1. **Predictability.** Explicit `resolve/1` calls show up in your + startup code and in profiles. Magic interception that fails + silently is harder to diagnose when a host you forgot to whitelist + breaks in production. +2. **Cost.** Resolving every hostname on every request adds a libc + round-trip even when the entry is already cached. Manual + `preresolve/1` at startup keeps the hot path zero-cost. +3. **Compatibility.** Some apps want to use a custom DNS server + (mDNS for service discovery, DNS-over-HTTPS for privacy). Manual + resolution leaves those paths open; automatic interception would + need to grow more configuration than the explicit call. + +If your app talks to a small fixed set of hosts (which most do), the +extra `preresolve/1` line at startup is the lowest-friction option. diff --git a/guides/troubleshooting.md b/guides/troubleshooting.md index f885b67b..ba5c6551 100644 --- a/guides/troubleshooting.md +++ b/guides/troubleshooting.md @@ -328,3 +328,33 @@ lsof -i :9101 If something else is using it, configure a different dist port in `Mob.Dist.ensure_started/1` and update `mob.exs` accordingly. + +--- + +## iOS: `Req` / `Finch` / `Mint` request fails with nxdomain on device + +**Symptom:** HTTPS calls that work everywhere else (host, simulator, Android) +fail on a physical iOS device. Errors look like `nxdomain`, `:einval`, or a +generic "lookup failed." + +**Cause:** BEAM's `inet_gethost` helper is spawned via `execve`, which iOS's +app sandbox forbids. Every hostname lookup through `:inet` fails immediately. +Android works because its OTP helpers ship as `lib*.so` in `jniLibs/`, which +SELinux allows to exec; iOS has no equivalent escape hatch. + +**Fix:** Call `Mob.DNS.resolve/1` once per backend before your first request, +typically in your app's `on_start/0`: + +```elixir +Mob.DNS.preresolve([ + "api.example.com", + "auth.example.com" +]) +``` + +After that, Req / Finch / Mint / `:httpc` / `gen_tcp` all work normally. + +See the [DNS on iOS guide](dns_on_ios.md) for the full story, including why +manual resolution rather than automatic interception, what to do if the IP +changes mid-session, and which libraries (NIFs that do their own +`getaddrinfo`) don't need this fix. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 6462872e..fa22883b 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -10,10 +10,13 @@ #import #import +#include #include #include +#include #include #include +#include // dlopen/dlsym are marked unavailable in iOS SDK headers but exist at runtime // in the iOS Simulator (macOS). Declare prototypes directly to bypass the header // restriction. On a real device these will be NULL (weak symbols). @@ -5514,6 +5517,96 @@ static ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL return enif_make_atom(env, "ok"); } +// ── NIF: resolve_ipv4/1 ────────────────────────────────────────────────────── +// +// In-process IPv4 DNS resolution via Darwin's libc getaddrinfo. Exists +// because BEAM's normal DNS path (`inet_gethost`, a port-program subprocess) +// is unrunnable on iOS — the sandbox forbids execve of bundled helper +// binaries. getaddrinfo is a libc function that runs in the app process +// with no exec / no sandbox interaction, so DNS via this NIF works where +// BEAM's built-in path doesn't. +// +// Callers should not invoke this NIF directly in app code. Use +// `Mob.DNS.resolve/1` (Elixir wrapper) which also seeds `:inet_db` so +// subsequent `:inet.getaddr/2` lookups by Req / Finch / Mint find the +// host. See `guides/dns_on_ios.md`. +// +// Dirty-scheduled because getaddrinfo can block on network for the full +// resolver timeout (sometimes seconds). Keeping it off regular schedulers +// avoids head-of-line blocking on every other BEAM activity. +// +// Returns: +// {:ok, {a, b, c, d}} +// {:error, :badarg} — host arg isn't a string/charlist +// {:error, :nxdomain} — no such hostname +// {:error, :timeout} — getaddrinfo TRY_AGAIN +// {:error, :no_address} — got a result but no IPv4 in the chain +// {:error, {:gai, code}} — anything else; `code` is the raw EAI_* int + +static ERL_NIF_TERM nif_resolve_ipv4(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char host[256]; + int got = enif_get_string(env, argv[0], host, sizeof(host), ERL_NIF_LATIN1); + + if (got <= 0) { + // got == 0 means the term wasn't a string; got < 0 means truncation. + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "badarg")); + } + + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo *result = NULL; + int err = getaddrinfo(host, NULL, &hints, &result); + + if (err != 0) { + const char *atom = NULL; + switch (err) { + case EAI_NONAME: + case EAI_NODATA: + atom = "nxdomain"; + break; + case EAI_AGAIN: + atom = "timeout"; + break; + default: + break; + } + if (atom) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, atom)); + } + // Anything else: surface the raw EAI_* code so the caller can + // distinguish or log it. + return enif_make_tuple2( + env, enif_make_atom(env, "error"), + enif_make_tuple2(env, enif_make_atom(env, "gai"), enif_make_int(env, err))); + } + + // Walk the result chain for the first AF_INET. getaddrinfo with + // ai_family=AF_INET should only return AF_INET entries but be + // defensive in case the resolver returns IPv6-mapped records. + ERL_NIF_TERM out_term = 0; + for (struct addrinfo *ai = result; ai != NULL; ai = ai->ai_next) { + if (ai->ai_family != AF_INET) + continue; + struct sockaddr_in *sin = (struct sockaddr_in *)ai->ai_addr; + uint32_t addr = ntohl(sin->sin_addr.s_addr); + out_term = enif_make_tuple2(env, enif_make_atom(env, "ok"), + enif_make_tuple4(env, enif_make_int(env, (addr >> 24) & 0xFF), + enif_make_int(env, (addr >> 16) & 0xFF), + enif_make_int(env, (addr >> 8) & 0xFF), + enif_make_int(env, addr & 0xFF))); + break; + } + freeaddrinfo(result); + + if (out_term == 0) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_address")); + } + return out_term; +} + void mob_send_component_event(int handle, const char *event, const char *payload_json) { if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) return; @@ -5639,6 +5732,10 @@ void mob_send_component_event(int handle, const char *event, const char *payload {"webview_go_back", 0, nif_webview_go_back, 0}, {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, + // getaddrinfo can block on the resolver for seconds — dirty-IO so it + // doesn't head-of-line-block the regular schedulers. See the impl + // above for the iOS rationale. + {"resolve_ipv4", 1, nif_resolve_ipv4, ERL_NIF_DIRTY_JOB_IO_BOUND}, }; static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { diff --git a/lib/mob/dns.ex b/lib/mob/dns.ex new file mode 100644 index 00000000..04624ecd --- /dev/null +++ b/lib/mob/dns.ex @@ -0,0 +1,181 @@ +defmodule Mob.DNS do + @moduledoc """ + Hostname → IP resolution that works around BEAM's broken DNS path + on iOS. + + ## Why this exists + + BEAM resolves hostnames by spawning an external helper called + `inet_gethost` (a port program). On macOS, Linux, Windows that + works fine. On **iOS** it doesn't — the iOS app sandbox forbids + `execve` of any binary the app didn't get a special pass for, and + there's no equivalent of Android's `lib*.so` escape hatch. + Result: `:inet.getaddr/2` (and therefore Req, Finch, Mint, + ReqLLM, and basically every Elixir HTTP library) fails on iOS + the moment a request hits a hostname rather than a literal IP. + + This module side-steps the problem by calling Darwin's + `getaddrinfo` directly via a NIF, then seeding `:inet_db` with + the result so subsequent BEAM-level lookups for the same host + succeed from the in-process file table. + + Android isn't affected — `mob_beam.zig` ships `inet_gethost` as + `libinet_gethost.so` in `jniLibs/`, which the SELinux policy + allows to `execve`. The NIF here would work on Android too but + isn't wired up by default; the BEAM path is already functional + there. + + ## How to use it + + Resolve each hostname your app talks to **before** the first + Req / Finch / Mint call to that host. Once resolved, `:inet_db` + retains the mapping for the lifetime of the BEAM, so subsequent + HTTP calls go through without you doing anything else. + + # At app startup, or before the first call: + {:ok, _ip} = Mob.DNS.resolve("api.example.com") + + # Now this just works on iOS: + Req.get!("https://api.example.com/v1/things") + + For a small fixed set of hosts, the convenience helper + `preresolve/1` does the whole list at once: + + Mob.DNS.preresolve([ + "api.example.com", + "auth.example.com" + ]) + + ## Scope and limitations + + - **IPv4 only.** Most cloud endpoints serve A records; IPv6 is a + follow-up if it becomes useful. + - **One IP per host.** If the hostname has multiple A records, + the first one is used. BEAM caches the result; failover isn't + automatic. If your endpoint cycles IPs frequently you may need + to re-resolve. + - **No automatic refresh.** Mappings stay in `:inet_db` until + the BEAM exits. If a backend's IP changes mid-session, the + cached entry will be stale — call `resolve/1` again to + refresh. + - **Doesn't help raw NIF networking.** If a third-party NIF calls + libc `getaddrinfo` itself, it never goes through BEAM's DNS + layer and doesn't need (or benefit from) this fix — it already + works on iOS. Only `:inet`-mediated lookups (which covers + almost all Elixir HTTP libraries) need our help. + - **iOS only effectively.** On Android and host (dev, macOS, + Linux) the NIF works but is unnecessary; BEAM's built-in path + is fine there. + + ## Errors + + {:ok, {a, b, c, d}} # success + {:error, :badarg} # host arg invalid + {:error, :nxdomain} # no such hostname + {:error, :timeout} # resolver TRY_AGAIN + {:error, :no_address} # resolved but no IPv4 + {:error, {:gai, code}} # raw getaddrinfo error code + {:error, :nif_not_loaded} # called off-device (host tests) + """ + + @typedoc "Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN." + @type host :: String.t() | charlist() + + @typedoc "The error shapes `resolve/1` can return." + @type error_reason :: + :badarg + | :nxdomain + | :timeout + | :no_address + | :nif_not_loaded + | {:gai, integer()} + + @doc """ + Resolve `host` to an IPv4 address and seed `:inet_db` so subsequent + `:inet.getaddr/2` lookups (and thus Req / Finch / Mint) find it. + + Idempotent — calling for the same host twice is harmless. + + See module doc for usage, scope, and error shapes. + """ + @spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()} + def resolve(host) when is_binary(host), do: resolve(String.to_charlist(host)) + + def resolve(host) when is_list(host) do + case safe_nif_call(host) do + {:ok, {_, _, _, _} = ip} -> + :inet_db.add_host(ip, [host]) + ensure_file_lookup_first() + {:ok, ip} + + {:error, _} = err -> + err + end + end + + @doc """ + Resolve a list of hostnames. Returns a map of host → result so the + caller can see which ones failed. + + Useful at app startup for the known-fixed set of backends your app + talks to. + + %{ + "api.example.com" => {:ok, {93, 184, 216, 34}}, + "auth.example.com" => {:error, :nxdomain} + } + """ + @spec preresolve([host()]) :: %{host() => {:ok, :inet.ip4_address()} | {:error, error_reason()}} + def preresolve(hosts) when is_list(hosts) do + Map.new(hosts, fn host -> {host, resolve(host)} end) + end + + @doc """ + True when `host` is already seeded in `:inet_db`. + + Useful for short-circuiting in caller code that wants to avoid an + unnecessary NIF call — but `resolve/1` is idempotent, so calling + it again is also fine. + """ + @spec resolved?(host()) :: boolean() + def resolved?(host) when is_binary(host), do: resolved?(String.to_charlist(host)) + + def resolved?(host) when is_list(host) do + case :inet.gethostbyname(host) do + {:ok, _} -> true + {:error, _} -> false + end + end + + # ── internals ───────────────────────────────────────────────────────── + + # Wrap the NIF call so we surface a structured error when running + # outside the device (host tests, IEx on the Mac before any deploy). + # Without this rescue, callers get an UndefinedFunctionError that's + # hard to interpret. + defp safe_nif_call(host) do + :mob_nif.resolve_ipv4(host) + rescue + UndefinedFunctionError -> {:error, :nif_not_loaded} + ErlangError -> {:error, :nif_not_loaded} + end + + # `:inet_db.set_lookup/1` controls the order BEAM tries lookup + # methods. Default on iOS includes `:native` (the broken + # `inet_gethost` path). We push `:file` to the front so seeded + # entries are found first. Idempotent: only modifies if `:file` + # isn't already in front. + defp ensure_file_lookup_first do + current = :inet_db.res_option(:lookup) + + case current do + [:file | _] -> + :ok + + _ -> + with_file = [:file | List.delete(current, :file)] + :inet_db.set_lookup(with_file) + :ok + end + end +end diff --git a/mix.exs b/mix.exs index 238a6f7c..28a61e6d 100644 --- a/mix.exs +++ b/mix.exs @@ -75,6 +75,7 @@ defmodule Mob.MixProject do "guides/theming.md": [title: "Theming"], "guides/navigation.md": [title: "Navigation"], "guides/device_capabilities.md": [title: "Device Capabilities"], + "guides/dns_on_ios.md": [title: "DNS on iOS"], "guides/push_notifications.md": [title: "Push Notifications"], "guides/data.md": [title: "Data & Persistence"], "guides/testing.md": [title: "Testing"], diff --git a/src/mob_nif.erl b/src/mob_nif.erl index ba468b7d..30cf29e5 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -177,7 +177,11 @@ webview_go_back/0, %% Native view components register_component/1, - deregister_component/1 + deregister_component/1, + %% DNS — in-process getaddrinfo so iOS apps bypass BEAM's + %% broken inet_gethost path. See `Mob.DNS` for the Elixir + %% wrapper and `guides/dns_on_ios.md` for the why. + resolve_ipv4/1 ]). -on_load(init/0). @@ -259,3 +263,4 @@ webview_can_go_back() -> erlang:nif_error(not_loaded). webview_go_back() -> erlang:nif_error(not_loaded). register_component(_Pid) -> erlang:nif_error(not_loaded). deregister_component(_Handle) -> erlang:nif_error(not_loaded). +resolve_ipv4(_Host) -> erlang:nif_error(not_loaded). diff --git a/test/mob/dns_test.exs b/test/mob/dns_test.exs new file mode 100644 index 00000000..2ee92232 --- /dev/null +++ b/test/mob/dns_test.exs @@ -0,0 +1,143 @@ +defmodule Mob.DNSTest do + use ExUnit.Case, async: false + + # `:inet_db` is process-shared; tests can't be async because they + # mutate the lookup chain + host table. Save and restore. + + alias Mob.DNS + + setup do + original_lookup = :inet_db.res_option(:lookup) + + on_exit(fn -> + # Restore the lookup order so other tests aren't affected. + :inet_db.set_lookup(original_lookup) + # Best-effort host-table cleanup for the names we used. + for host <- + ~c"a.test a.test.local b.test missing.test bogus.test" + |> List.to_string() + |> String.split() do + :inet_db.del_host(String.to_charlist(host)) + end + end) + + :ok + end + + # ── resolve/1 — host tests work without the NIF loaded ────────────────── + + describe "resolve/1 when the NIF isn't loaded (host / CI)" do + test "returns {:error, :nif_not_loaded} for a binary host" do + assert {:error, :nif_not_loaded} = DNS.resolve("api.example.com") + end + + test "returns {:error, :nif_not_loaded} for a charlist host" do + assert {:error, :nif_not_loaded} = DNS.resolve(~c"api.example.com") + end + + test ":inet_db is NOT polluted when the NIF fails" do + # Important: a failed resolve must not leave a half-seeded entry. + _ = DNS.resolve("a.test") + refute DNS.resolved?("a.test"), "host must not be seeded after NIF failure" + end + end + + # ── resolve/1 — happy path simulated by directly seeding inet_db ──────── + # + # We can't easily intercept the NIF call without a runtime DI seam, but + # we can pin the post-condition: when an IP IS in inet_db (regardless + # of who put it there), `resolved?/1` reports true and BEAM's lookup + # finds it. Combined with the NIF-error tests above, the wrapper logic + # is fully covered modulo the trivial `enif_make_*` mapping in C. + + describe "resolved?/1" do + test "false for a host that's not in inet_db" do + refute DNS.resolved?("never.seeded.test") + end + + test "true after seeding inet_db AND setting :file-first lookup" do + # This is the post-condition `resolve/1` establishes on a real + # device. Replicate it manually here since the NIF doesn't run + # in host tests. + :inet_db.set_lookup([:file, :native]) + :inet_db.add_host({203, 0, 113, 7}, [~c"manual.seeded.test"]) + + assert DNS.resolved?("manual.seeded.test") + end + + test "accepts both binary and charlist forms" do + :inet_db.set_lookup([:file, :native]) + :inet_db.add_host({203, 0, 113, 8}, [~c"both.forms.test"]) + + assert DNS.resolved?("both.forms.test") + assert DNS.resolved?(~c"both.forms.test") + end + + test "false when an entry exists in inet_db but the lookup chain skips :file" do + # Defensive — pin the chain-dependence semantics. If someone + # manually adds a host but the chain doesn't include :file, + # resolved?/1 (and any Req/Finch lookup) correctly reports + # "not findable." resolve/1 sets the chain, so users following + # the documented path won't hit this. + :inet_db.set_lookup([:native]) + :inet_db.add_host({203, 0, 113, 9}, [~c"chain.bypass.test"]) + + refute DNS.resolved?("chain.bypass.test") + end + end + + # ── preresolve/1 ─────────────────────────────────────────────────────── + + describe "preresolve/1" do + test "returns a host → result map covering every input" do + result = DNS.preresolve(["a.test", "b.test"]) + + assert map_size(result) == 2 + assert Map.has_key?(result, "a.test") + assert Map.has_key?(result, "b.test") + end + + test "preserves per-host failures rather than failing the whole batch" do + result = DNS.preresolve(["a.test", "b.test"]) + + # On the host without the NIF every entry is :nif_not_loaded. + for {_host, outcome} <- result do + assert {:error, :nif_not_loaded} = outcome + end + end + + test "empty list → empty map" do + assert DNS.preresolve([]) == %{} + end + end + + # ── Lookup-chain side effects ────────────────────────────────────────── + # + # On host BEAM the default lookup is `[:native]` — adding a host to the + # file table is NOT enough on its own; you also need `:file` in the + # chain. This is exactly the situation `resolve/1` works around by + # pushing `:file` to the front. Pin the contract. + + describe ":inet_db lookup chain" do + test "seeding a host alone is not enough — the chain must include :file" do + # Same seed as the "happy path" tests above, but WITHOUT mutating + # the lookup chain. On a default-config BEAM, `resolved?/1` should + # report false because `:native` doesn't see the file table. + :inet_db.add_host({203, 0, 113, 99}, [~c"chain.test"]) + + # If this ever flips to true on a future OTP, it means the default + # chain changed to include `:file`. Update the comment and + # consider whether `resolve/1` still needs `ensure_file_lookup_first/0`. + refute DNS.resolved?("chain.test") + end + + test "after pushing :file to the front, the seeded host IS findable" do + # This is the post-condition `resolve/1` establishes. Replicating + # it confirms the wrapper's chain-mutation strategy is sound. + :inet_db.add_host({203, 0, 113, 99}, [~c"chain.test"]) + :inet_db.set_lookup([:file | :inet_db.res_option(:lookup)]) + + assert DNS.resolved?("chain.test") + end + end +end From 509d999edf359d79e7b3cf9bdbcefdd996b67ec7 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 18:58:45 -0600 Subject: [PATCH 039/254] issues: file 3 NIF flow findings from empirical verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 zigler scaffold ships :zigler ~> 0.15 which uses removed Zig stdlib APIs (@Type signature, std.fs.File.stdout) — incompatible with the pinned zig-0.17.0-dev. Scaffold writes the right files; the dep itself doesn't compile. #16 rustler scaffold's default cdylib fails the macOS host link with "Undefined symbols: _enif_raise_exception, _enif_schedule_nif" because we don't emit .cargo/config.toml with -undefined dynamic_lookup. Iteration on the host-dev path is blocked until the user knows the fix. #17 NIF surface discoverability — mob.add_nif covers c/zigler/rustler but pythonx lives under mob.enable. Splitting "scaffold your own" from "wire a third-party prebuilt NIF dep" is conceptually right (future TFLite/OpenCV/RocksDB NIF deps would go under mob.enable too), but a user typing mob.add_nif --help has no signpost. Recommended fix: add a thin discoverability alias from mob.add_nif --type pythonx to mob.enable pythonx. All verified 2026-05-12 in ~/code/test_migration. --- issues.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/issues.md b/issues.md index 0f6fdb21..25e19862 100644 --- a/issues.md +++ b/issues.md @@ -873,3 +873,165 @@ sim verification. Use `Mob.Test` against any connected physical device **Where this matters** — every dev iteration on a sim. This is the agentic-coding loop's foundation; if `Mob.Test` doesn't work against the sim, agents lose the fast path and burn cycles on screenshots. + +--- + +## 15. `mix mob.add_nif --type zigler` ships a `:zigler ~> 0.15` pin that doesn't build on current Zig + +**Symptom** — After scaffolding with `mix mob.add_nif foo --type zigler`, +`mix compile` fails inside the zigler dep's sema phase: + +``` +/_build/dev/lib/zigler/priv/beam/get.zig:718:12: error: invalid builtin function: '@Type' + return @Type(.{ .@"struct" = constructed_struct }); +/_build/dev/lib/zigler/priv/beam/payload.zig:36:12: error: invalid builtin function: '@Type' + return @Type(result_type_info); +/_build/dev/lib/zigler/priv/beam/sema.zig:282:27: error: root source file struct 'fs' has no member named 'File' + const stdout = std.fs.File.stdout(); +``` + +**Why** — `:zigler ~> 0.15` resolves to `zigler 0.15.2`, which targets a +Zig stdlib snapshot from before the `@Type` builtin signature change +and before `std.fs.File.stdout()` was removed. The installed Zig +(currently `0.17.0-dev.269+ebff43698`, the version mob_dev builds with) +is past both changes. + +The Elixir-side scaffolding itself is correct — it emits a clean +`use Zig, otp_app: :app` module with an example `pub fn` in a `~Z` +sigil. The bug is only the version pin. + +**Fix options** + +1. **Bump the zigler dep pin.** Check what version of zigler (if any) + tracks Zig 0.17-dev. If a newer zigler release is compatible, bump + the version in `MobDev.AddNif.maybe_add_zigler_dep/2`. + +2. **Pin Zig instead.** Mob already pins a specific Zig version via + `~/zig/zig-aarch64-macos-0.17.0-dev.269+ebff43698/`. If zigler 0.15 + needs an earlier Zig, document the supported range, or vendor a + second Zig install for the zigler path. + +3. **Skip zigler-via-Hex entirely.** Zigler's "compile a .so" model + doesn't fit Mob's static-link constraint anyway (the moduledoc + already warns about this). The static-link path requires manual + wire-up regardless of zigler. Consider removing `--type zigler` + from `mob.add_nif` and pointing users at writing the Zig directly + through the existing `ios/build.zig` + `android/jni/*.zig` + pipelines that the framework already uses. + +**Where this matters** — anyone trying `mix mob.add_nif --type zigler` +hits this on first compile. The error is multi-line stdlib-internal +output that doesn't suggest "your version pin is wrong" — easy to +read as "Zigler is broken" and give up. + +**Empirically verified 2026-05-12** in `~/code/test_migration` against +`zigler 0.15.2` + `zig 0.17.0-dev.269`. The Elixir scaffold ran cleanly +(stub + mob.exs entry + driver_tab regen all succeeded); the failure is +purely the dep's Zig source not matching the installed Zig. + +--- + +## 16. `mix mob.add_nif --type rustler` Rust crate fails to link on macOS host (no `-undefined dynamic_lookup`) + +**Symptom** — After scaffolding with `mix mob.add_nif foo --type rustler`, +`mix compile` invokes Cargo which fails the link step: + +``` +Undefined symbols for architecture arm64: + "_enif_raise_exception", referenced from: + rustler::codegen_runtime::NifReturned::apply in librustler-*.rlib + "_enif_schedule_nif", referenced from: + rustler::codegen_runtime::NifReturned::apply in librustler-*.rlib +ld: symbol(s) not found for architecture arm64 +error: could not compile `foo_rustler` (lib) due to 1 previous error +``` + +**Why** — Rustler's default `crate-type = ["cdylib"]` builds a `.dylib` +that gets `dlopen`'d at NIF load. The `enif_*` symbols come from the +*host* BEAM process at load time, not from a library the .dylib links +against. Apple's `ld` errors out on the undefined symbols unless told +explicitly to defer them. + +On Linux this isn't an issue (`ld.bfd`/`ld.lld` defer by default). On +macOS, Rustler-on-host needs `.cargo/config.toml`: + +```toml +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] +``` + +Our scaffolding doesn't emit this file. The user hits the cryptic +link error and has to know to search "Rustler macOS undefined symbols" +to find the answer. + +**Fix** — `MobDev.AddNif.add_rustler_files/3` (the writer that creates +`native//Cargo.toml` + `src/lib.rs` + `.gitignore`) should also +emit `native//.cargo/config.toml` with the dynamic_lookup +rustflags pinned for both Apple targets. + +Note the static-link path that Mob actually ships with is different — +the moduledoc warns this scaffold's default `cdylib` won't work on +iOS/Android anyway; the user has to switch to `staticlib` and wire +the resulting `.a` into `ios/build.zig` + `android/jni/`. But the +host-dev flow (sim, `mix run`) should at least compile cleanly so +the user can iterate before doing the static-link work. + +**Empirically verified 2026-05-12** in `~/code/test_migration` against +`rustler 0.37.3`. Scaffold succeeded; first `mix compile` failed at +the cdylib link step on macOS arm64. + +--- + +## 17. NIF surface discoverability — `--python` vs `--type {c, zigler, rustler}` + +**Symptom** — Three NIF-related Mix surfaces, three different shapes: + +- `mix mob.add_nif --type {c, zigler, rustler, elixir-only}` + — scaffold a *new* NIF (you write the native side). +- `mix mob.enable pythonx` — wire a *pre-built* hex NIF dep (CPython) + into an existing project, including OTP-bundle changes. +- `mix mob.new --python` — sugar for "generate project then enable + pythonx". + +A user thinking "I want to add a NIF" finds `mob.add_nif`, sees C/ +Zigler/Rustler under `--type`, and reasonably wonders why pythonx +isn't there. + +**Why the split exists** — they're conceptually different: + +- `add_nif` produces *stubs to fill in* (your own C/Rust/Zig). +- `enable pythonx` *wires a third-party prebuilt NIF dep* — there's no + user-written native code, but there IS OTP-runtime work (bundling + Python.framework on iOS, packaging the Android Python lib dir). + +Future third-party NIF deps that need similar bundling work (a +TensorFlow Lite wrapper, an OpenCV wrapper, a RocksDB NIF) would +naturally also live under `mob.enable`, not `mob.add_nif`. Conflating +the two surfaces will eventually break. + +**Fix options** + +1. **Add a discoverability alias.** `mob.add_nif --type pythonx` + becomes a thin shim that prints `"pythonx is a third-party dep, + delegating to mob.enable pythonx"` and chains to it. Cheap; keeps + the conceptual split clean; surfaces the right command via the + wrong one. + +2. **Document the split in both task moduledocs.** `mob.add_nif`'s + `@moduledoc` mentions "for third-party NIF deps, see `mob.enable`"; + `mob.enable`'s mentions the inverse. Cheapest; relies on users + reading `--help`. + +3. **Keep both routes.** `mob.add_nif --type pythonx` does the same + thing as `mob.enable pythonx`. Most consistent surface, but + conflates the two concepts conceptually (a user might then expect + `mob.add_nif --type tflite` to also Just Work). + +**Recommendation** — (1) for now. The split is conceptually right, +but discoverability is poor. + +**Where this matters** — when a user types `mix mob.add_nif --help` +and tries to figure out how to add Python. From 32625747cf7c21035dd83963a6513ced197921ea Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 20:15:45 -0600 Subject: [PATCH 040/254] mob_nif: fix missing -export for resolve_ipv4/1; pin invariant in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DNS NIF merge declared resolve_ipv4/1 in the -nifs([...]) list and provided a stub clause but forgot to add the name to the -export([...]) list. The Erlang compiler accepts this silently with just a "function unused" warning. The failure mode surfaces only on iOS device: Crash dump slogan: Runtime terminating during boot ({undef,[{mob_nif,log,["step 1 starting"],[]}, ...]}) beam_stdout.log: The on_load function for module mob_nif returned: {:error, {:bad_lib, "Function not found mob_nif:resolve_ipv4/1"}} When `erlang:load_nif/2` finds a C-side `nif_funcs[]` entry whose name isn't exported by the Erlang module, it rejects the whole library with `bad_lib`. The on_load callback returns error → BEAM purges the entire mob_nif module → every subsequent call (mob_nif:log/1 is the first one during BEAM boot) is :undef → BEAM exits before any screen renders. Fix: add resolve_ipv4/1 to -export. Empirically verified end-to-end on a physical iPhone — Req.get on https://genericjam.com/... now returns {:ok, %{status: 200, body: ...}}. Pin the contract in test/mob/nif_stub_test.exs: - Every name in -nifs must be in -export (the bug above). - Every name in -nifs must have a stub clause raising erlang:nif_error(not_loaded) at matching arity. The tests parse src/mob_nif.erl directly because OTP doesn't expose the -nifs declaration via the standard beam_lib attributes chunk. Also expanded guides/dns_on_ios.md with four gotchas that surfaced during empirical verification: 1. Mob's iOS launcher doesn't auto-start hex deps; HTTP clients (:req, :finch) need Application.ensure_all_started in on_start. 2. TLS trust store — castore in deps isn't enough; need to either pass cacertfile explicitly or use :public_key.cacerts_load!/0. 3. Stale :inet_db if a backend's IP rotates mid-session. 4. Hot-push doesn't re-run on_start/0; need --native for changes to on_start to take effect after a restart. --- guides/dns_on_ios.md | 88 ++++++++++++++++++++++++++ src/mob_nif.erl | 4 +- test/mob/nif_stub_test.exs | 124 +++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 test/mob/nif_stub_test.exs diff --git a/guides/dns_on_ios.md b/guides/dns_on_ios.md index 0279eea0..79272e70 100644 --- a/guides/dns_on_ios.md +++ b/guides/dns_on_ios.md @@ -261,3 +261,91 @@ route because: If your app talks to a small fixed set of hosts (which most do), the extra `preresolve/1` line at startup is the lowest-friction option. + +--- + +## Other gotchas (empirically discovered) + +Once `Mob.DNS` is in place, the next failures down the HTTPS stack +are easy to misread as "DNS still broken." They aren't — they're +separate issues that the iOS-device deployment surfaces because +the BEAM bootstrap is more minimal than a normal Mix project. If +your request still fails after seeding DNS, check these: + +### 1. Start the HTTP client's application + +Mob's iOS launcher (`mob_beam.m`) boots a minimal BEAM: +`compiler` → `elixir` → `logger` → `.start/0`. That's +*all*. Hex dependencies like `:req` are **not auto-started** — the +normal OTP `applications:` list in your `.app` file isn't being +consulted by this boot path. + +If Req's `Finch` supervisor isn't running you'll see: + +``` +GenServer.call(Req.FinchSupervisor, ...) +** (EXIT) no process: the process is not alive ... +``` + +Fix: explicitly start the HTTP client and the cert store in your +`on_start/0`, after `Mob.DNS.preresolve/1`: + +```elixir +def on_start do + # ... your usual startup ... + + {:ok, _} = Application.ensure_all_started(:req) + {:ok, _} = Application.ensure_all_started(:castore) + + Mob.DNS.preresolve(["api.example.com"]) + + Mob.Screen.start_root(MyApp.HomeScreen) +end +``` + +Same pattern for `:finch`/`:mint`/`:httpoison`/`:tesla` if you're +using those directly. + +### 2. TLS trust store — `:castore` or `:public_key.cacerts_load!/0` + +Mint's HTTPS path verifies the server certificate by default and +needs a CA bundle. On iOS-device builds the default +`Application.app_dir(:castore, "priv/cacerts.pem")` path doesn't +always resolve correctly even with castore in deps. Two working +options: + +```elixir +# Option A — explicit CA file via transport opts +Req.get(url, connect_options: [transport_opts: [cacertfile: ...]]) + +# Option B — load the OS CA store (OTP 25+) +:public_key.cacerts_load!() +Req.get(url, connect_options: [ + transport_opts: [cacerts: :public_key.cacerts_get()] +]) +``` + +For dev / spike testing where you don't care about cert validation, +`verify: :verify_none` works but **never ship this**: + +```elixir +Req.get(url, connect_options: [transport_opts: [verify: :verify_none]]) +``` + +### 3. Stale `:inet_db` if the IP rotates + +`Mob.DNS.resolve/1` seeds `:inet_db` once per BEAM lifetime. If the +backend's IP changes mid-session (DNS round-robin, blue/green +deploy), subsequent requests will keep hitting the cached IP until +you call `resolve/1` again. For long-running apps that talk to +volatile endpoints, schedule a periodic re-resolve. + +### 4. Hot-push doesn't re-run `on_start/0` + +`mix mob.deploy` (without `--native`) hot-loads new BEAMs via +`:code.load_binary` — the running app's `on_start/0` is *not* +re-invoked, and the on-disk `.beam` files in the app's Documents +dir aren't updated. If you change `on_start/0` (e.g., to add the +`ensure_all_started` calls above), use `mix mob.deploy --native` +to actually reinstall the app with the new beams on disk so a +restart will pick them up. diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 30cf29e5..d3a34f4e 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -95,7 +95,9 @@ key_press/1, clear_text/0, long_press_xy/3, - swipe_xy/4 + swipe_xy/4, + %% DNS — see Mob.DNS and guides/dns_on_ios.md + resolve_ipv4/1 ]). -nifs([ diff --git a/test/mob/nif_stub_test.exs b/test/mob/nif_stub_test.exs new file mode 100644 index 00000000..cbe6b8b1 --- /dev/null +++ b/test/mob/nif_stub_test.exs @@ -0,0 +1,124 @@ +defmodule Mob.NifStubTest do + use ExUnit.Case, async: true + + # Pins the contract between `-nifs([...])`, `-export([...])`, and the + # function clauses in `src/mob_nif.erl`. This caught a real bug: + # `resolve_ipv4/1` was added to `-nifs` and defined as a stub, but + # forgotten in `-export`. The Erlang compiler accepts that quietly + # (just emits a "function unused" warning); the failure mode surfaces + # only on-device when `erlang:load_nif/2` rejects the NIF library + # with `{bad_lib, "Function not found mob_nif:/"}`, + # the module is purged, and every call to mob_nif becomes `:undef`. + # + # The test parses the .erl source rather than reading the .beam + # because OTP doesn't expose the `-nifs` declaration in the standard + # attributes chunk. + + @source Path.expand("../../src/mob_nif.erl", __DIR__) + + setup_all do + src = File.read!(@source) + {:ok, exports: parse_block(src, "-export"), nifs: parse_block(src, "-nifs")} + end + + test "the -nifs declaration is non-empty (sanity)", %{nifs: nifs} do + assert length(nifs) > 0 + end + + test "every -nifs entry has a matching -export entry", %{ + exports: exports, + nifs: nifs + } do + # The Erlang compiler doesn't enforce this. Forgetting an export + # for a name in -nifs is silently accepted at build time but blows + # up at runtime as `bad_lib: Function not found mob_nif:/` + # on the device — and because that's an on_load failure, the + # module is purged and ALL mob_nif calls become :undef. The first + # symptom you see is `mob_nif:log/1` failing during BEAM boot. + missing = nifs -- exports + + assert missing == [], + "Names in -nifs but not in -export — the iOS-device NIF load will " <> + "fail with `Function not found` and the module will be purged.\n" <> + "Missing: #{inspect(missing)}" + end + + test "every -nifs entry has a stub clause that raises nif_error", %{ + nifs: nifs + } do + # Each NIF must have a fallback definition `(_, _, ...) -> + # erlang:nif_error(not_loaded).`. Without it, callers on host / + # in tests / before the NIF loads hit `:undef` instead of the + # documented `:not_loaded` atom. + src = File.read!(@source) + + missing = + Enum.filter(nifs, fn {name, arity} -> + # Match `(args) -> erlang:nif_error(not_loaded).` — args + # are typically `_Foo, _Bar` matching the arity. + pattern = ~r/^#{Regex.escape(Atom.to_string(name))}\([^)]*\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + + not (Regex.match?(pattern, src) and + arity_matches?(src, name, arity)) + end) + + assert missing == [], + "Names in -nifs without a stub clause raising nif_error(not_loaded):\n" <> + inspect(missing) + end + + # ── helpers ────────────────────────────────────────────────────────────── + + # Parse the contents of `-([ name/arity, ... ]).` into a + # list of `{:name, arity}` tuples. Lines may be wrapped, the inner + # list may have comments (skipped) and a trailing comma is allowed. + defp parse_block(src, keyword) do + case Regex.run(~r/#{Regex.escape(keyword)}\(\[(.*?)\]\)\./s, src) do + [_, inner] -> + inner + |> String.split("\n") + |> Enum.map(&strip_comment/1) + |> Enum.join(" ") + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&parse_name_arity/1) + |> Enum.reject(&is_nil/1) + + _ -> + [] + end + end + + defp strip_comment(line) do + case String.split(line, "%", parts: 2) do + [code, _comment] -> code + [code] -> code + end + end + + defp parse_name_arity(entry) do + case Regex.run(~r/^([a-z][a-z0-9_]*)\/(\d+)$/, entry) do + [_, name, arity] -> {String.to_atom(name), String.to_integer(arity)} + _ -> nil + end + end + + # The regex in the stub-clause test only checks that *some* clause + # for `name` exists. This narrows to "name with an arity-matching + # arg list." + defp arity_matches?(src, name, arity) do + name_str = Atom.to_string(name) + # Count commas + 1 = arity (or 0 args = no commas, name() form). + pattern = + if arity == 0 do + ~r/^#{Regex.escape(name_str)}\(\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + else + # Match `name(_A1, _A2, ...)` with exactly `arity` underscore-prefixed args. + args = Enum.map(1..arity, fn _ -> "_[A-Za-z0-9_]*" end) |> Enum.join(",\\s*") + ~r/^#{Regex.escape(name_str)}\(#{args}\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + end + + Regex.match?(pattern, src) + end +end From 7e32ef9a6787b7f6182c2d7710e58079be38dd39 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 21:00:26 -0600 Subject: [PATCH 041/254] issues: #15 zigler partial-fix + #16 rustler FIXED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #16 closed: scaffold now emits native//.cargo/config.toml with -undefined dynamic_lookup for both Apple target triples. Empirically verified compiling on macOS arm64. #15 partial: pin/PATH-priority bug fixed (scaffold now auto-runs `mix zig.get`). macOS 26 incompat is upstream Zig stdlib issue (undefined __availability_version_check) — resolved when Zigler supports Zig 0.16+. Linux and older macOS work. --- issues.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/issues.md b/issues.md index 25e19862..79ddd9cc 100644 --- a/issues.md +++ b/issues.md @@ -876,7 +876,28 @@ the sim, agents lose the fast path and burn cycles on screenshots. --- -## 15. `mix mob.add_nif --type zigler` ships a `:zigler ~> 0.15` pin that doesn't build on current Zig +## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch (FIXED for the PATH-priority bug; **partial: macOS 26 upstream incompat remains**, 2026-05-12) + +**Resolution (partial, 2026-05-12, mob_dev commit forthcoming):** The +scaffold now queues `mix zig.get` after adding the `:zigler ~> 0.15` +dep, so Zigler installs and uses its pinned Zig 0.15.2 from the +user-cache directory instead of falling through to +`System.find_executable("zig")` (which on this machine picks up the +mob-pinned 0.17-dev). A test pins the contract: every +`--type zigler` scaffold run must queue `zig.get`. The moduledoc on +the generated stub now spells out the toolchain pin so users +understand why `mix zig.get` ran. + +**Still broken on macOS 26 (Sequoia/Tahoe):** even with the correct +Zig 0.15.2, building the example NIF on macOS 26 fails with a +cascade of undefined symbols starting with +`__availability_version_check`. This is a Zig-stdlib / +compiler_rt issue tracked upstream — Zig 0.15 was built before +macOS 26's tighter library linking and references SDK symbols that +the newer linker won't resolve. The fix is Zig 0.16+ (which Zigler +0.15.2 doesn't yet support). Linux and older macOS are unaffected. + + **Symptom** — After scaffolding with `mix mob.add_nif foo --type zigler`, `mix compile` fails inside the zigler dep's sema phase: @@ -931,7 +952,30 @@ purely the dep's Zig source not matching the installed Zig. --- -## 16. `mix mob.add_nif --type rustler` Rust crate fails to link on macOS host (no `-undefined dynamic_lookup`) +## 16. `mix mob.add_nif --type rustler` Rust crate fails to link on macOS host (no `-undefined dynamic_lookup`) — **FIXED 2026-05-12** + +**Resolution (2026-05-12, mob_dev commit forthcoming):** The +scaffold now emits `native//.cargo/config.toml` with the +required `rustflags` for both Apple targets: + +```toml +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] +``` + +Empirically verified: scaffolding `mix mob.add_nif foo_rustler +--type rustler` and running `mix compile` on macOS arm64 now +succeeds (links to `priv/native/foo_rustler.so`). A test pins +the contract — every `--type rustler` scaffold run creates a +`.cargo/config.toml` with both targets and the dynamic_lookup +flags. + +Linux is unaffected — `rustflags` scope is Apple-only. + + **Symptom** — After scaffolding with `mix mob.add_nif foo --type rustler`, `mix compile` invokes Cargo which fails the link step: From 62281ccc0a356333c3017d4860089a478dfafd92 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 22:17:46 -0600 Subject: [PATCH 042/254] issues: file #18 c_src auto-wiring + verify --demo flow works #18 documents the missing piece for `mob.add_nif --type c` to be truly user-friendly: the iOS/Android build templates don't yet auto-discover `c_src/*.c` files declared in `mob.exs :static_nifs`. Until they do, users need a hand-edited addCObject block in ios/build_device.zig with -DSTATIC_ERLANG_NIF + -DSTATIC_ERLANG_NIF_LIBNAME=. The mob.add_nif scaffold's generated c_src/.c now spells out the exact block to copy in its file header. The right long-term fix is in the build templates themselves (mob_new). Empirically verified end-to-end on iPhone: --demo flag scaffolds a working demo that prints "Hello from C!" after manual build wiring. Mob.add_nif's --demo PR (mob_dev d87bbda) lands the scaffold half; this issue tracks the build-template half. --- issues.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/issues.md b/issues.md index 79ddd9cc..2430a427 100644 --- a/issues.md +++ b/issues.md @@ -1079,3 +1079,73 @@ but discoverability is poor. **Where this matters** — when a user types `mix mob.add_nif --help` and tries to figure out how to add Python. + +--- + +## 18. `mob.add_nif --type c` doesn't auto-wire `c_src/.c` into the iOS/Android build + +**Symptom** — After `mix mob.add_nif foo --type c`, the next native +build (`mix mob.deploy --native`) leaves `c_src/foo.c` unlinked. +On Elixir-side, `:erlang.load_nif/2` fails with: + +``` +The on_load function for module Elixir..Nifs.Foo returned: + {:error, {:load_failed, + "Failed to load NIF library: 'dlopen(foo.so, 0x0006): tried: ...'"}} +``` + +(BEAM fell through from the static-NIF table to dlopen because +nothing registered `_nif_init` at link time.) + +The C scaffold's moduledoc currently tells the user to do this +manually — but the right scaffolding action is to auto-wire it. + +**Why this matters now** — `--demo` made this gap visible because +the demo flow expects the C NIF to actually work. Verified manually: +hand-adding an `addCObject` block + `-DSTATIC_ERLANG_NIF +-DSTATIC_ERLANG_NIF_LIBNAME=` flags to `ios/build_device.zig` +gets the demo working end-to-end (Hello from C! on iPhone). + +**Fix shape** + +1. **iOS** — the `build_device.zig` template (in `mob_new`) and + `build.zig` (sim) should iterate `:static_nifs` from `mob.exs` + and emit an `addCObject` block for each entry that has a + corresponding `c_src/.c` file. The `c_flags` need + `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=` baked + in. + +2. **Android** — equivalent in `android/jni/CMakeLists.txt`: glob + `${PROJECT_ROOT}/c_src/*.c` (or read `mob.exs :static_nifs`) + and add to `target_sources` with the same -D flags. + +3. **`mob.regen_driver_tab`** could grow a side-effect that lists + which `c_src/*.c` files exist and warns if the project's + `build.zig` / CMakeLists isn't picking them up. Belt-and-braces. + +**Workaround until then** — hand-edit `ios/build_device.zig` to add: + +```zig +installAndCollect(b, objects_step, &objs, addCObject(b, .{ + .name = "", + .source = "/c_src/.c", + .target = target, + .optimize = optimize, + .c_flags = c_flags_base ++ &[_][]const u8{ + "-DSTATIC_ERLANG_NIF", + "-DSTATIC_ERLANG_NIF_LIBNAME=", + }, + .mob_dir = mob_dir, + .otp_root = otp_root, + .erts_vsn = erts_vsn, + .sdkroot = sdkroot, +}), ".o"); +``` + +The two -D flags are mandatory: without `STATIC_ERLANG_NIF_LIBNAME`, +`ERL_NIF_INIT(Elixir.App.Nifs.Foo, ...)` mangles to an invalid C +symbol name (dots in identifiers don't compile). + +**Empirically verified 2026-05-12** via the demo screen flow in +`~/code/test_migration`. The full diagnosis lives in +`mob_dev/lib/mix/tasks/mob.add_nif.ex`'s `c_skeleton/3` docstring. From ad4c3d69afa1f001b7a132af8d3f61a1e5f0ba5c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 12 May 2026 23:43:41 -0600 Subject: [PATCH 043/254] issues: #18 expanded to cover Rustler path; verified Rust on iPhone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- issues.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/issues.md b/issues.md index 2430a427..c9a115bb 100644 --- a/issues.md +++ b/issues.md @@ -1082,7 +1082,7 @@ and tries to figure out how to add Python. --- -## 18. `mob.add_nif --type c` doesn't auto-wire `c_src/.c` into the iOS/Android build +## 18. NIF source auto-wiring missing for `mob.add_nif --type {c, rustler}` builds **Symptom** — After `mix mob.add_nif foo --type c`, the next native build (`mix mob.deploy --native`) leaves `c_src/foo.c` unlinked. @@ -1149,3 +1149,62 @@ symbol name (dots in identifiers don't compile). **Empirically verified 2026-05-12** via the demo screen flow in `~/code/test_migration`. The full diagnosis lives in `mob_dev/lib/mix/tasks/mob.add_nif.ex`'s `c_skeleton/3` docstring. + +### Rustler is in the same boat (verified 2026-05-12) + +Same gap, harder shape: + +1. **Cargo `crate-type`** — scaffolded as `cdylib` (for host-dev + ergonomics). iOS device needs `staticlib`. Add both: + `crate-type = ["staticlib", "cdylib"]`. The Mob scaffold should + emit this dual form by default — host-dev still gets the + `.dylib`, iOS device gets the `.a`. +2. **Cross-compile target** — `rustup target add aarch64-apple-ios` + is a one-time setup the scaffold doesn't run. `mix mob.doctor` + could check for this and prompt. +3. **Invoke cross-compile** — Rustler's mix integration only knows + about the host target. iOS device needs: + ```bash + cd native/ && cargo rustc --release \ + --target aarch64-apple-ios --crate-type staticlib + ``` + This isn't wired into `mix mob.deploy --native`. +4. **Link the `.a` into iOS build** — hand-add `run.addArg(...)` + for `native//target/aarch64-apple-ios/release/lib.a` + inside `addLink()` in `ios/build_device.zig`. Same pattern as + the `sqlite_static_lib` hook already there. +5. **Rustler crate version pin** — scaffold currently pins + `rustler = "0.32"` in the generated `Cargo.toml`. Rustler 0.32 + hardcodes `nif_init` (no per-crate symbol). Rustler 0.37+ derives + `_nif_init` from `CARGO_CRATE_NAME` automatically, which + is exactly what mob's static-NIF table expects. **Bump the + Cargo.toml template to `rustler = "0.37"` (or latest).** +6. **rustler::init! deprecation** — the macro warns "deprecated: + only one argument expected" with the 0.37 form. The scaffold's + `rustler::init!("Elixir.", [greet]);` should drop the + functions list and use `#[rustler::nif]` exclusively (auto- + discovery via inventory). + +**Empirically verified 2026-05-12 on physical iPhone**: +- Scaffolded `mix mob.add_nif greet_rust --type rustler --demo --yes` +- Hand-bumped `Cargo.toml` to `rustler = "0.37"` and added + `staticlib` to crate-type. +- `cargo rustc --release --target aarch64-apple-ios --crate-type staticlib` +- Hand-added the `.a` to addLink's lib list in `build_device.zig`. +- `mix mob.deploy --native --ios-device` → succeeds. +- `Mob.Test.tap(node, :run)` → + `result: "Hello from Rust!"` and + `[info] [greet_rust-nif] call 1 returned: "Hello from Rust!"` + +So the path works; what's missing is automation. Steps 1-2 are +scaffold-side (mob_dev). Steps 3-4 are build-template-side +(mob_new templates). Step 5 is a one-line bump. Step 6 is a +template polish. + +### Zigler — blocked upstream + +`mob.add_nif --type zigler --demo` fails at host compile on macOS 26 +before iOS even enters the picture (issue #15). Until Zigler supports +Zig 0.16+, no automated iOS-device path is possible on this Mac. +Linux and older macOS users can verify zigler --demo end-to-end +following the same pattern as C/Rust above. From 44f2e45225bdb007231b6311dd19f6360952fe33 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 00:12:02 -0600 Subject: [PATCH 044/254] issues: #18 NIF source auto-wiring FIXED (iOS) + Android follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS device + sim now auto-wire `c_src/*.c` and `native//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. --- issues.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/issues.md b/issues.md index c9a115bb..08a62721 100644 --- a/issues.md +++ b/issues.md @@ -1082,7 +1082,52 @@ and tries to figure out how to add Python. --- -## 18. NIF source auto-wiring missing for `mob.add_nif --type {c, rustler}` builds +## 18. NIF source auto-wiring missing for `mob.add_nif --type {c, rustler}` builds — **FIXED 2026-05-13** + +**Resolution (2026-05-13, mob_dev + mob_new).** Auto-wiring landed +in both build templates and the Mix-task build pipeline. `mix mob.deploy +--native --ios-device` now: + +1. Reads `:static_nifs` from `mob.exs`, filtering to user-declared entries + (the baked-in NIFs in `MobDev.StaticNifs.default_nifs/0` are excluded + — those live in `libbeam.a`). +2. For each entry, runs `MobDev.NativeBuild.classify_project_nif/2`: + - `c_src/.c` exists → C path + - `native//Cargo.toml` exists → Rust path + - neither → `:elixir_only` (no native wiring, just the stub raises) +3. For Rust NIFs: invokes `cargo rustc --release --target aarch64-apple-ios + --crate-type staticlib --manifest-path native//Cargo.toml` + (or the `-sim` target for iOS sim builds). +4. Passes the resolved lists to `zig build` as `-D` args: + - `-Dproject_root=` + - `-Dproject_c_nifs=` + - `-Dproject_rust_libs=` +5. `build_device.zig` (and `build.zig` for sim) iterates the names and + emits `addCObject` for each C NIF with + `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=`, and adds + each Rust `.a` to the linker's lib list. + +**Empirically verified 2026-05-13 on physical iPhone:** scaffolded +`greet_c --type c --demo` and `greet_rust --type rustler --demo` +in test_migration with **zero hand-editing of `build_device.zig`**. +`mix mob.deploy --native --ios-device` succeeded; `Mob.Test.tap` on +both demo screens returned the expected strings (`~c"Hello from C!"` +and `"Hello from Rust!"`). + +Old workaround code in this issue's earlier history (the hand-edit +sample) is now superseded — the scaffold's pre-deploy step does it +all. The companion changes from this same session (Cargo.toml emits +`["staticlib", "cdylib"]` and `rustler = "0.37"`) make the cross-compile +step Just Work without user intervention. + +**Still hand-work for new project setup**: one-time `rustup target add +aarch64-apple-ios` (and `aarch64-apple-ios-sim` for sim). `mix mob.doctor` +could prompt for this — filed as a follow-up. + +**Android auto-wiring** (`android/jni/CMakeLists.txt` reading +`:static_nifs`) is still to do — the iOS work establishes the pattern. + + **Symptom** — After `mix mob.add_nif foo --type c`, the next native build (`mix mob.deploy --native`) leaves `c_src/foo.c` unlinked. From 080e62011be42c1947cf59b10c8bf1a1598ae806 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 08:19:16 -0600 Subject: [PATCH 045/254] =?UTF-8?q?issues:=20#15=20=E2=80=94=20Zigler=200.?= =?UTF-8?q?16=20host-dev=20fork=20landed;=20iPhone=20path=20still=20pendin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- issues.md | 51 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/issues.md b/issues.md index 08a62721..70b99202 100644 --- a/issues.md +++ b/issues.md @@ -876,7 +876,7 @@ the sim, agents lose the fast path and burn cycles on screenshots. --- -## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch (FIXED for the PATH-priority bug; **partial: macOS 26 upstream incompat remains**, 2026-05-12) +## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch (FIXED for the PATH-priority bug + macOS 26 host-dev path via fork, 2026-05-13; **iPhone deploy still pending — Zigler lacks cross-compile**) **Resolution (partial, 2026-05-12, mob_dev commit forthcoming):** The scaffold now queues `mix zig.get` after adding the `:zigler ~> 0.15` @@ -888,14 +888,47 @@ mob-pinned 0.17-dev). A test pins the contract: every the generated stub now spells out the toolchain pin so users understand why `mix zig.get` ran. -**Still broken on macOS 26 (Sequoia/Tahoe):** even with the correct -Zig 0.15.2, building the example NIF on macOS 26 fails with a -cascade of undefined symbols starting with -`__availability_version_check`. This is a Zig-stdlib / -compiler_rt issue tracked upstream — Zig 0.15 was built before -macOS 26's tighter library linking and references SDK symbols that -the newer linker won't resolve. The fix is Zig 0.16+ (which Zigler -0.15.2 doesn't yet support). Linux and older macOS are unaffected. +**macOS 26 host-dev (FIXED 2026-05-13 via fork).** Forked Zigler +to `github.com/GenericJam/zigler` branch `zig-016-port` with a +minimal port of `priv/beam/` to Zig 0.16's stdlib. Zig 0.16.0 +stable (released 2026-04-13) works on macOS 26. The mob_dev +scaffold now pulls Zigler from this fork by default. Once +upstream Zigler ships a 0.16 release (community issue #578), +the dep pin flips back to hex. + +**Port details:** 5 files changed in priv/beam/, ~50 net lines. +The Zig 0.15→0.16 stdlib breaking changes that hit Zigler: + + - `std.fs.File.stdout()` moved to `std.Io.File.stdout()`, and + `File.writer/1` now takes an `Io` instance as its first arg + (sema.zig + sema_doc.zig) + - `@Type(.{ .@"struct" = ... })` split into per-variant + builtins: `@Struct`, `@Tuple`, `@Enum`, `@Union`, `@Pointer`, + `@Int`, `@Fn`, `@Vector`. Parallel-array signatures replace + the old array-of-records (get.zig + payload.zig) + - `std.debug.SelfInfo.open(allocator)` removed — replaced by + zero-value `init` const + per-method `Io` parameter. Stubbed + out for now (stacktrace.zig); NIF crashes lose per-frame + source-location info until proper port lands. + +**Empirically verified on macOS 26.4:** + + iex> TestMigration.Nifs.GreetZig.greet() + "Hello from Zig!" + +**Still pending — iPhone deploy.** Zigler 0.15.x has no cross- +compile target or `staticlib` crate-type options. Its builder +emits a `dylib` for the host only. Getting `--type zigler --demo` +working on iPhone requires either: + + 1. Extending Zigler's build pipeline to accept a target triple + + crate_type (a feature add, not a port) + 2. Bypassing Zigler's build entirely for the on-device path + and using `zig build-lib --target aarch64-ios --crate-type + static` directly with the user's `~Z` source + +Option 1 is the right contribution to upstream Zigler — file as +a follow-up. Option 2 is more invasive in mob_dev. From 42b48797ff4529b256f5469cc66b1124f1deca73 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 08:45:18 -0600 Subject: [PATCH 046/254] =?UTF-8?q?issues:=20#15=20=E2=80=94=20iOS=20Zigle?= =?UTF-8?q?r=20foundation=20landed,=20isysroot=20blocker=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- issues.md | 64 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/issues.md b/issues.md index 70b99202..7805a6e3 100644 --- a/issues.md +++ b/issues.md @@ -916,19 +916,57 @@ The Zig 0.15→0.16 stdlib breaking changes that hit Zigler: iex> TestMigration.Nifs.GreetZig.greet() "Hello from Zig!" -**Still pending — iPhone deploy.** Zigler 0.15.x has no cross- -compile target or `staticlib` crate-type options. Its builder -emits a `dylib` for the host only. Getting `--type zigler --demo` -working on iPhone requires either: - - 1. Extending Zigler's build pipeline to accept a target triple - + crate_type (a feature add, not a port) - 2. Bypassing Zigler's build entirely for the on-device path - and using `zig build-lib --target aarch64-ios --crate-type - static` directly with the user's `~Z` source - -Option 1 is the right contribution to upstream Zigler — file as -a follow-up. Option 2 is more invasive in mob_dev. +**iPhone deploy — foundation landed in fork; one piece pending in +Zigler itself (2026-05-13).** + +Two Zigler-fork build options added (GenericJam/zigler 2f17e63): + + -Dnif_linkage=static + produces a `.a` instead of the default dylib/so/dll. + linkage flows into `b.addLibrary(.linkage = ...)`; the + `linker_allow_shlib_undefined` flag is skipped for static + (it's a dynamic-library concept). + + -Dnif_init_alias=_nif_init + adds an additional `@export` of nif_init under that name. + Static-NIF table lookup matches `_nif_init`; the + default `nif_init` symbol is always kept so dlopen also + works. Both names point to the same function. + +Verified on a host-target sanity build: `nm libElixir..a` +shows both `_nif_init` AND `_greet_zig_nif_init` exported. + +mob_dev plumbing landed in 2c405c5: `classify_project_nif/2` +detects `:zig` from a `use Zig` stub, `cross_compile_zig_nif` +invokes `zig build -Dtarget=… -Dnif_linkage=static +-Dnif_init_alias=…` against Zigler's staging dir, output `.a` +flows into the iOS link via the existing `project_rust_libs` arg. + +**Blocker — Apple SDK headers in cImport.** When `zig build` runs +the cross-compile for `aarch64-ios-none`, it cImports `erl_nif.h` +which transitively pulls `sys/types.h`. Zig's cImport on iOS +targets needs `-isysroot $iPhoneOS_SDK_path`. Zigler's `erl_nif` +module hardcodes the host Erlang include path and doesn't expose +an isysroot knob: + + error: 'sys/types.h' not found + # include + +Fixing this needs another Zigler-side change — pass an isysroot +through `addCSourceFile`/cImport calls when the build target is +Apple. The upstream Zigler 0.16 work the author has planned for +next week is the natural place for this; we'll either pick up +their fix or contribute the isysroot patch then. + +**Status summary** + + Host (macOS 26) ✓ via fork + iOS device (real) ✗ blocked on Zigler isysroot/cImport + iOS sim untested (same blocker expected) + Android untested (different SDK story) + +Linux + older macOS users can verify the host path end-to-end +following the same scaffold flow. From 30a796eff51aaa7b413d7f396a1b9831860444c3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 09:09:18 -0600 Subject: [PATCH 047/254] =?UTF-8?q?issues:=20#15=20=E2=80=94=20Zigler=20iP?= =?UTF-8?q?hone=20deploy=20FIXED=20via=20fork=20(verified=20end-to-end)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- issues.md | 64 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/issues.md b/issues.md index 7805a6e3..ac8cc52b 100644 --- a/issues.md +++ b/issues.md @@ -876,7 +876,7 @@ the sim, agents lose the fast path and burn cycles on screenshots. --- -## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch (FIXED for the PATH-priority bug + macOS 26 host-dev path via fork, 2026-05-13; **iPhone deploy still pending — Zigler lacks cross-compile**) +## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch — **FIXED 2026-05-13 via GenericJam/zigler fork (interim until upstream catches up)** **Resolution (partial, 2026-05-12, mob_dev commit forthcoming):** The scaffold now queues `mix zig.get` after adding the `:zigler ~> 0.15` @@ -942,31 +942,55 @@ invokes `zig build -Dtarget=… -Dnif_linkage=static -Dnif_init_alias=…` against Zigler's staging dir, output `.a` flows into the iOS link via the existing `project_rust_libs` arg. -**Blocker — Apple SDK headers in cImport.** When `zig build` runs -the cross-compile for `aarch64-ios-none`, it cImports `erl_nif.h` -which transitively pulls `sys/types.h`. Zig's cImport on iOS -targets needs `-isysroot $iPhoneOS_SDK_path`. Zigler's `erl_nif` -module hardcodes the host Erlang include path and doesn't expose -an isysroot knob: +**iPhone empirical verification on 2026-05-13:** - error: 'sys/types.h' not found - # include + iex> Mob.Test.tap(node, :run); Mob.Test.assigns(node) + %{result: "Hello from Zig!", ...} -Fixing this needs another Zigler-side change — pass an isysroot -through `addCSourceFile`/cImport calls when the build target is -Apple. The upstream Zigler 0.16 work the author has planned for -next week is the natural place for this; we'll either pick up -their fix or contribute the isysroot patch then. + [info] [greet_zig-nif] call 1 returned: "Hello from Zig!" + (visible in Mac-side IEx via mix mob.connect) + +Two additional fork patches needed beyond the linkage + alias +options for iPhone: + + -Dapple_sdkroot= + Resolves Apple-target cImport headers (sys/types.h etc.). + mob_dev calls `xcrun --show-sdk-path -sdk iphoneos` and + passes the result. Empty/unset → host build (no SDK + injection needed). + + module.zig: pub const panic = ... if alias set, no_panic, else + simple_panic + Default panic pulls in std.debug.SelfInfo for stack traces, + which on Mach-O references dyld functions + (`_dyld_get_image_header_containing_address`). Not linkable + in static archives going into an embedded BEAM. Swap to + no_panic (trap-only, no SelfInfo) when alias is set + (our static-build marker). Host still gets simple_panic for + readable error messages. **Status summary** Host (macOS 26) ✓ via fork - iOS device (real) ✗ blocked on Zigler isysroot/cImport - iOS sim untested (same blocker expected) - Android untested (different SDK story) - -Linux + older macOS users can verify the host path end-to-end -following the same scaffold flow. + iOS device (real) ✓ via fork (verified 2026-05-13) + iOS sim untested (mob_dev plumbing reuses iOS + device path; SDK swap is the only diff) + Android untested (different SDK story — needs + NDK sysroot threading, parallel work + to Apple SDK) + +The fork (`github.com/GenericJam/zigler` branch `zig-016-port`) +is the interim until Isaac's upstream 0.16 release ships with +the iOS / cross-compile fixes integrated. The mechanism we +landed should drop in cleanly: + + - 5-file priv/beam/ port to Zig 0.16 stdlib (the actual 0.16 work) + - `-Dnif_linkage=static` build option + - `-Dnif_init_alias=_nif_init` build option (writes + additional `@export`) + - `-Dapple_sdkroot=` build option (addSystemIncludePath + on erl_nif module) + - `pub const panic` selection based on alias presence From 5b2ce5a30928318a6b97e2382e3414daab288857 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 09:25:09 -0600 Subject: [PATCH 048/254] =?UTF-8?q?issues:=20file=20#19=20=E2=80=94=20Andr?= =?UTF-8?q?oid=20NIF=20auto-wiring=20(port=20the=20iOS=20work)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors what we just did for iOS in #18 + #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. --- issues.md | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/issues.md b/issues.md index ac8cc52b..ad5a6a7f 100644 --- a/issues.md +++ b/issues.md @@ -1348,3 +1348,247 @@ before iOS even enters the picture (issue #15). Until Zigler supports Zig 0.16+, no automated iOS-device path is possible on this Mac. Linux and older macOS users can verify zigler --demo end-to-end following the same pattern as C/Rust above. + +--- + +## 19. Android NIF auto-wiring — port the iOS work for `--type {c, rustler, zigler}` deploys + +**Status:** Open, ready for a fresh session. iOS pattern landed in +issues #18 + #15 (both FIXED 2026-05-13); Android is the parallel +work that mirrors it for the `aarch64-linux-android` target. + +### Goal + +End-to-end verification of all three demo scaffolds on a physical +Android device (or emulator): + + $ mix mob.add_nif greet_c --type c --demo --yes + $ mix mob.add_nif greet_rust --type rustler --demo --yes + $ mix mob.add_nif greet_zig --type zigler --demo --yes + $ mix mob.deploy --native --android + $ # via Mob.Test from a Mac-side IEx: + $ Mob.Test.tap(node, :run); Mob.Test.assigns(node).result + ~c"Hello from C!" # or "Hello from Rust!" / "Hello from Zig!" + +Plus the matching `[info] [-nif] call 1 returned: ...` +Logger line visible in `adb logcat` (or via dist to a Mac-side +IEx — see CLAUDE.md for `mix mob.connect` setup). + +`moto e` and the `sdk_gphone64_arm64` emulators are typically +connected in this workspace. Prefer the physical `moto e` because +emulators sometimes mask SELinux/dlopen quirks (see issue #10). + +### Reference: where the iOS pattern lives + +The iOS work is a clean template to mirror. Read these first: + +- **mob_dev cross-compile helpers** — + `lib/mob_dev/native_build.ex`: + - `project_nif_user_entries/0` (filters out baked-in NIFs) + - `classify_project_nif/2` — returns + `{:c, _} | {:rust, _} | {:zig, _} | :elixir_only` + - `cross_compile_rust_nifs/2` + `rust_target_for(:android)` → + `"aarch64-linux-android"` (already wired, just not invoked) + - `cross_compile_zig_nifs/2` + `zig_build_target_for(:android)` + → `"aarch64-linux-android"` (same — wired but unused) + - `project_nif_zig_args/1` — gathers everything and emits + `-Dproject_root=`, `-Dproject_c_nifs=`, `-Dproject_rust_libs=` + +- **iOS build template consumer** — + `mob_new/priv/templates/mob.new/ios/build_device.zig.eex` + + the matching `ios/build.zig.eex` for sim: + - Reads the `-D` options + - Iterates `project_c_nifs` and emits `addCObject` per name + with `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=` + - Appends each `project_rust_libs` `.a` to the linker line + +- **Apple-SDK plumbing for Zigler cImport** — + GenericJam/zigler fork `zig-016-port`, commit `e2a4c19`. Adds + `-Dapple_sdkroot=...` build option used by the build template + to `addSystemIncludePath` on the `erl_nif` module. + +### Android-specific surface (what needs to change) + +Android uses a different build stack: **Gradle → CMake → NDK +clang**, plus its own `build.zig` for the BEAM library +(mob's Phase 2 work moved most of the native build into +`zig build`). Each layer is parallel-but-different from iOS. + +**1. `cross_compile_*_nifs` already handles `:android` — just +hook them in.** mob_dev currently only calls +`project_nif_zig_args` from `zig_build_binary_ios_device` and +`zig_build_binary_ios_sim`. Add a parallel call from +`run_zig_android_objects` (line ~184 of native_build.ex) so the +flags propagate to the Android `zig build` invocation. + +**2. Rust prerequisites the scaffold doesn't yet check.** + - `rustup target add aarch64-linux-android` + - `cargo-ndk` *or* manual NDK linker env vars + (`CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER` etc.) so cargo + can find the NDK's `aarch64-linux-android-clang` + - `mix mob.doctor` should warn if missing — same shape as the + iPhone-target check filed in #18. + +**3. Zigler fork needs an `android_sdkroot` companion to +`apple_sdkroot` — IF NEEDED. Verify first.** Zig 0.16's +`aarch64-linux-android` target may already bundle Bionic libc +headers (unlike Apple, where `cImport` truly needs the SDK +headers). Run the cross-compile FIRST and see what (if anything) +fails on cImport. If headers are bundled, this step is a no-op. + + If needed: same insertion site + (`zigler/lib/zig/templates/build_mod.zig.eex`), same mechanism + as `apple_sdkroot`. mob_dev's `ndk_sysroot/0` (line ~216 of + native_build.ex) already resolves the NDK path — pass it as + `-Dandroid_sdkroot=` to Zigler's build. Probably ~15 + lines in the fork; lands alongside the existing + `apple_sdkroot` commit (e2a4c19). + +**4. `android/app/src/main/jni/build.zig.eex` consumes the +project NIF flags.** This is the Android counterpart to +`ios/build_device.zig.eex`. The template (in mob_new) iterates +`project_c_nifs` to emit objects into `zig-out//` and adds +each `project_rust_libs` `.a` to the final link. Same shape as +the iOS template; different file. + +**5. `CMakeLists.txt.eex` fallback paths.** The Android +CMakeLists template has three paths +(`mob_new/priv/templates/mob.new/android/app/src/main/jni/CMakeLists.txt.eex`, +lines ~28-60): + + 1. zig-built `lib.so` in `jniLibs//` — Gradle picks + it up directly (the happy path under mob) + 2. zig-built `.o` files in `zig-out//` — CMake links them + 3. `.c` sources fallback — CMake compiles via NDK clang + + Paths 1 and 2 are covered by step 4. Path 3 is for non-Mix + invocations (Android Studio "Sync Project", standalone + `./gradlew assembleDebug`) — emit `target_sources` for each + `c_src/.c` with the right `-DSTATIC_ERLANG_NIF` flags. + +**6. Symbol naming + static-NIF table.** Same as iOS: +`ERL_NIF_INIT(Elixir., ...)` with +`-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=` for C; +rustler 0.37+ auto-derives `_nif_init` for Rust; Zigler +fork's `-Dnif_init_alias=_nif_init` for Zig. No new +mechanism — these all already work; just need to be **invoked** +from the Android build. + +**7. `driver_tab_android.zig` regeneration.** +`mix mob.regen_driver_tab` already handles this. The generated +table declares `_nif_init` for every entry in `mob.exs +:static_nifs` — verified via `priv/generated/driver_tab_android.zig` +after `mix mob.add_nif greet_c --type c --demo --yes`. No change. + +### Likely gotchas + +- **SELinux on Android 17+** (issue #10) — physical device may + refuse `dlopen`/`execve` of certain `lib*.so` files. NIFs + going through the static-table path should be unaffected + (they're in the main `.so`), but worth a sanity check on the + `moto e` if anything weird shows up. + +- **JNI symbol stripping** — Android `--gc-sections` strips + unreferenced symbols aggressively. The existing + `enif_keepalive` table covers BEAM's `enif_*` API; verify it + also covers any symbols the project NIFs introduce. + +- **Multi-ABI** — mob currently builds both `arm64-v8a` AND + `armeabi-v7a` (see `zig_build_android_objects` loop). Project + NIFs need to cross-compile for both. The Rust 32-bit target is + `armv7-linux-androideabi`; Zig's `arm-linux-androideabi`. + Plumbing both ABIs may be the longest pole — **start with + arm64 only for the demo**, file 32-bit as a follow-up. + +- **rustup targets may not be installed.** Run + `rustup target add aarch64-linux-android` early and surface a + clear error if it fails. Sequester it from the user's iOS-only + Rust setup if they have one. + +- **`cargo-ndk` vs raw env vars.** `cargo-ndk` simplifies path + resolution but adds a tool dep. Raw env vars (e.g. + `CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER`) avoid the dep but + are more brittle. Pick one approach and document the choice in + the scaffold's moduledoc + `mob.doctor`. + +### Scope guardrails + +**In scope:** +- iOS-pattern parity for `arm64-v8a` (most users) +- All three `--demo` flows end-to-end on a connected Android +- mob_dev plumbing + mob_new template changes +- Zigler fork's `android_sdkroot` option *if needed* (verify first) + +**Out of scope (file as follow-ups):** +- `armeabi-v7a` (32-bit) cross-compile — arm64 first +- `mix mob.doctor` prerequisite checks for rustup targets / NDK +- Pythonx on Android — Python bundling is its own thing + (`nif_future.md` issue #2) + +### Done definition + +- All three `--demo` scaffolds (`c`, `rustler`, `zigler`) deploy + to a real Android device via `mix mob.deploy --native --android` + with **zero hand-editing** of CMakeLists.txt, build.zig, or + Cargo.toml. +- `Mob.Test.tap(node, :run)` on each demo screen returns the + expected greeting on Android the same way it does on iPhone + (verified for iOS in issues #15 + #18). +- Logger output `[info] [-nif] call 1 returned: ...` + visible in `adb logcat` (or via dist to Mac-side IEx). +- Tests + credo clean in mob_dev. + +### Verification recipe + + $ # Prerequisites + $ rustup target add aarch64-linux-android + $ # (verify NDK is configured — android/local.properties has sdk.dir + $ # and either ndk.dir or ANDROID_NDK_HOME) + $ mix mob.doctor # should report no missing prerequisites + + $ cd ~/code/test_migration # known-good scratch project + $ rm -rf lib/test_migration/nifs native c_src # clean slate + $ # reset mob.exs to drop old :static_nifs entries + + $ # Scaffold all three demos + $ for type in c rustler zigler; do + mix mob.add_nif greet_$type --type $type --demo --yes + done + + $ # Build + deploy + $ mix mob.deploy --native --android + + $ # Drive each demo via dist + $ # Node name is `test_migration_android_@127.0.0.1`; + $ # `mix mob.devices` lists exact names per attached device. + $ # See CLAUDE.md → "Connecting an IEx session to a running mob app" + $ # for the dist setup. mob/lib/mob/test.ex is the harness. + +### Suggested commit shape + +One logical PR per layer so each is independently verifiable: + +1. **mob_dev**: invoke `project_nif_zig_args` from the Android + build path. Confirms cross-compiles run (they may not link + yet because templates don't consume the args). +2. **mob_new**: `android/app/src/main/jni/build.zig.eex` + consumes `-Dproject_c_nifs` / `-Dproject_rust_libs`. Confirms + C demo works. +3. **mob_new**: `CMakeLists.txt.eex` mirrors for the fallback + paths (Studio sync + standalone gradle). Confirms emulator + + Studio builds. +4. *(if verified-needed)* **GenericJam/zigler fork**: + `-Dandroid_sdkroot=...`. Confirms Zig demo works. +5. **issues.md**: flip #19 to FIXED with empirical results. + +### Related issues to read first + +- **#18** — iOS auto-wiring, the pattern to mirror +- **#15** — Zigler fork (the four fork patches the agent will + extend with `android_sdkroot` if needed) +- **#10** — Android 17 SELinux constraints (open; may or may not + affect this work) +- **CLAUDE.md** in mob — Android deploy / multi-device / dist-port + story; required reading +- **mob_dev/AGENTS.md** — TDD discipline, "tests cover everything" + including build helpers From 34f50ee127a565561e48f86477610d2bed88fa0d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 09:42:58 -0600 Subject: [PATCH 049/254] nif_future + nif_stub_test: catch up with merged iOS wheel-copy fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nif_future.md item #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. --- nif_future.md | 62 +++++++++++++++++++++++++++++++++++--- test/mob/nif_stub_test.exs | 3 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/nif_future.md b/nif_future.md index fcd16dcd..f9093b1f 100644 --- a/nif_future.md +++ b/nif_future.md @@ -139,17 +139,71 @@ test cases when the new build pipeline reaches the deploy step. --- -## 4. iOS device build skips `copy_project_python_wheels` (verified 2026-05-11, **FIXED 2026-05-11**) +## 4. iOS device build skips `copy_project_python_wheels` (verified 2026-05-11, **FIXED 2026-05-11 19:10 PT on branch `fix/ios-wheel-copy`** — re-verified end-to-end on iPhone SE 3rd gen) + +**Resolution (2026-05-11 evening, branch `fix/ios-wheel-copy`, +commit `78ebf2e` on `deps/mob_dev`)**: `bundle_otp_runtime/4` in +`lib/mob_dev/native_build.ex` now calls a new +`copy_ios_safe_project_python_wheels/1` right after the python rsync +into `.app/otp/python/`. The helper mirrors the Android +`copy_project_python_wheels/1` pattern but filters out wheels that +contain any `.so` extension — today's `priv/python_wheels/` ships +Chaquopy-compatible Android binaries under names like +`_cffi_backend.so` and `_rust.so` (no "android" in the filename), so a +name-based filter misses them. "Has any `.so`" matches the current +reality: pure-Python wheels (rns, lxmf, pyserial, pycparser) land, +Android-only ones get skipped with a `[ios-wheels] skipped` log line. +RNS falls back to its internal crypto provider when `cryptography` +isn't importable, so the pure-Python subset is enough to bring the +Reticulum stack up. + +Note: `ios/build_device.sh:179` still nukes +`/python/Python.framework` and +`/python/lib/python3.13` on every build — so a +stage-into-the-cache workaround would not survive. Doing the wheel +copy in `bundle_otp_runtime/4` (which runs AFTER the rsync into the +`.app`) sidesteps that. + +**Verification (iPhone SE 3rd gen 00008110-001E1C3A34F8401E)**: +- `Pigeon.app/otp/python/lib/python3.13/site-packages/` now contains + `RNS/`, `LXMF/`, `serial/`, `pycparser/`, `chaquopy/` (metadata-only) + plus their `*.dist-info/` directories. +- BEAM boot trace (via temporary `Pigeon.App.on_start` file logger): + `on_start enter` → `backend=Pigeon.Transport.Reticulum` → + `python init start` → `python init ok` (+124 ms) → + `transport start (…)` → `transport started ok` (+2.5 s). +- Process stays alive (`xcrun devicectl device info processes` + shows Pigeon running). Previously exited cleanly at the + `{:ok, _transport_sup} = …` pattern match. + +The historical 2026-05-11 morning + 2026-05-11 17:40 PT notes +below are kept for context. -**Resolution**: `mob_dev` `lib/mob_dev/native_build.ex` — +--- + +### Earlier note: 2026-05-11 17:40 PT — "did not actually land" + +The 2026-05-11 morning note claimed the iOS device path was wired to +`copy_project_python_wheels/1` via `maybe_setup_pythonx_sim/5` / +`maybe_setup_pythonx_device/5`. Re-check on 2026-05-11 17:40 PT showed +neither helper nor either call site existed in `deps/mob_dev` HEAD — +the prior fix attempt didn't land. That's what triggered the current +fix on branch `fix/ios-wheel-copy`. + +--- + +### Earlier note that turned out to be inaccurate + +`mob_dev` `lib/mob_dev/native_build.ex` — `copy_project_python_wheels/1` generalised (param renamed `assets_root` → `python_root`, docstring covers both platforms) and wired into both `maybe_setup_pythonx_sim/5` (right after the lib-dynload `copy_dir!`) and `maybe_setup_pythonx_device/5` (right after the lib-dynload `cp_r!`). Both call sites pass `/python` as the root — same `lib/python3.13/site-packages/` -suffix as Android, so the helper works unchanged. The historical -notes below are kept for context. +suffix as Android, so the helper works unchanged. **Re-check on +2026-05-11 evening shows neither helper nor either call site exists +in `deps/mob_dev` HEAD; whatever was intended did not land.** --- diff --git a/test/mob/nif_stub_test.exs b/test/mob/nif_stub_test.exs index cbe6b8b1..9599d61d 100644 --- a/test/mob/nif_stub_test.exs +++ b/test/mob/nif_stub_test.exs @@ -56,7 +56,8 @@ defmodule Mob.NifStubTest do Enum.filter(nifs, fn {name, arity} -> # Match `(args) -> erlang:nif_error(not_loaded).` — args # are typically `_Foo, _Bar` matching the arity. - pattern = ~r/^#{Regex.escape(Atom.to_string(name))}\([^)]*\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + pattern = + ~r/^#{Regex.escape(Atom.to_string(name))}\([^)]*\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m not (Regex.match?(pattern, src) and arity_matches?(src, name, arity)) From 935e3551784110adac37a2184fb70745044b5ff9 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 11:43:16 -0600 Subject: [PATCH 050/254] driver_tab_ios.zig: handle emlx_static alongside sqlite_static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ios/driver_tab_ios.zig | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/ios/driver_tab_ios.zig b/ios/driver_tab_ios.zig index c9fa1d35..3e8880cf 100644 --- a/ios/driver_tab_ios.zig +++ b/ios/driver_tab_ios.zig @@ -70,9 +70,15 @@ extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; // system threads the flag in via `b.addOptions()` in build_device.zig // (iter 3); see addZigObject. For simulator builds the option module // either isn't provided OR has sqlite_static = false. +// +// emlx_nif is linked statically when the project opts into MLX via +// `mix mob.enable mlx`. Same threading mechanism — a separate flag +// keeps the two NIFs independent. const build_options = @import("build_options"); const sqlite_static = build_options.sqlite_static; +const emlx_static = build_options.emlx_static; extern fn sqlite3_nif_nif_init() callconv(.c) ?*anyopaque; +extern fn emlx_nif_nif_init() callconv(.c) ?*anyopaque; // ── Static driver table ──────────────────────────────────────────────────── // inet + ram_file are the only drivers in the iOS bundle. NULL-terminator @@ -107,13 +113,20 @@ const base_nifs = [_]ErtsStaticNif{ .{ .nif_init = mob_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, }; -const sqlite_nif = ErtsStaticNif{ +const sqlite3_nif_const = ErtsStaticNif{ .nif_init = sqlite3_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null, }; +const emlx_nif_const = ErtsStaticNif{ + .nif_init = emlx_nif_nif_init, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + const sentinel = ErtsStaticNif{ .nif_init = null, .is_builtin = 0, @@ -121,9 +134,16 @@ const sentinel = ErtsStaticNif{ .entry = null, }; +// 2^N branching: one branch per subset of enabled guarded NIFs. Order +// matters — most-specific subsets first so the comptime `if` doesn't +// mistakenly take a shadowed branch. export var erts_static_nif_tab = blk: { - if (sqlite_static) { - break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite_nif, sentinel }; + if (sqlite_static and emlx_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite3_nif_const, emlx_nif_const, sentinel }; + } else if (emlx_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ emlx_nif_const, sentinel }; + } else if (sqlite_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite3_nif_const, sentinel }; } else { break :blk base_nifs ++ [_]ErtsStaticNif{sentinel}; } From 862d78b05a9920ab709ed680a8f2b386ae75934b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 17:16:04 -0600 Subject: [PATCH 051/254] deps: add ex_slop Credo check for AI-generated Elixir patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .credo.exs | 2 ++ mix.exs | 4 ++++ mix.lock | 1 + 3 files changed, 7 insertions(+) diff --git a/.credo.exs b/.credo.exs index 65ae29d7..7ec1e293 100644 --- a/.credo.exs +++ b/.credo.exs @@ -11,6 +11,8 @@ enabled: [ {Credo.Check.Readability.Specs, files: %{excluded: ["test/"]}}, {Credo.Check.Refactor.UnlessWithElse, []}, + # ex_slop — runs the recommended bundle of AI-slop checks. + {ExSlop, []}, # jump_credo_checks {Jump.CredoChecks.AvoidFunctionLevelElse, []}, {Jump.CredoChecks.AvoidLoggerConfigureInTest, []}, diff --git a/mix.exs b/mix.exs index 28a61e6d..b4de7b49 100644 --- a/mix.exs +++ b/mix.exs @@ -159,6 +159,10 @@ defmodule Mob.MixProject do {:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false}, + # ex_slop — Credo check that catches AI-generated Elixir patterns + # (blanket rescue, narrator-style docs, redundant Enum chains, etc). + # Wired in via .credo.exs as `{ExSlop, []}` in the enabled list. + {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, {:erlfmt, "~> 1.8", only: :dev, runtime: false}, # Known Elixir 1.20-rc.4 dep warning (cosmetic, dev-only): # lib/mix_unused/filter.ex:61 — `_.._ inside match is deprecated`. diff --git a/mix.lock b/mix.lock index c3fda888..15ca4e79 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,7 @@ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlfmt": {:hex, :erlfmt, "1.8.0", "6df9379029a09f60b5c07d631c376f31d32dbf36a59f021b4a56f0b8825db468", [:rebar3], [], "hexpm", "f783ca8a8367c92f96ec75c8fee2c636efd0f39ac45ff57d8d825a71b4b957d3"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "ex_slop": {:hex, :ex_slop, "0.4.0", "06c39628e2a278a9adeaf76047f7b98002a453b53a38b48faa3921835675c680", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "563da973e0251ebd69785a21873ea566158c95b123a5dccf075c0c687e4acc2e"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, From a2f562d6715636dd74b8d782e91c184b7ff971f1 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 18:30:11 -0600 Subject: [PATCH 052/254] docs(CLAUDE.md): note ExSlop runs alongside mix credo --strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b870b129..636713dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Before committing changes, run **all** in this order: ```bash mix test # full suite must pass (call out any pre-existing flake explicitly) mix format # apply Elixir formatting -mix credo --strict # **whole tree, not just changed files** — pre-existing issues are tracked separately, but new ones (including in tests) must be fixed +mix credo --strict # **whole tree, not just changed files** — includes ExSlop (catches AI-generated patterns: blanket rescue, narrator docs, etc). Pre-existing issues are tracked separately, but new ones (including in tests) must be fixed mix erlfmt --check src/ # Erlang formatting (src/mob_nif.erl) xcrun clang-format --dry-run -Werror \ ios/*.m ios/*.c \ From 000ff865e15838447e34fe6345f871b143b7f887 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 18:41:06 -0600 Subject: [PATCH 053/254] refactor: ex_slop mechanical wins in mob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/mob/registry.ex | 4 +--- test/mob/device_test.exs | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/mob/registry.ex b/lib/mob/registry.ex index 45e3e31c..ba2ce4bb 100644 --- a/lib/mob/registry.ex +++ b/lib/mob/registry.ex @@ -98,8 +98,6 @@ defmodule Mob.Registry do # ── Private ─────────────────────────────────────────────────────────────── defp build_initial do - Enum.reduce(@builtins, %{}, fn {name, mappings}, acc -> - Map.put(acc, name, Map.new(mappings)) - end) + Map.new(@builtins, fn {name, mappings} -> {name, Map.new(mappings)} end) end end diff --git a/test/mob/device_test.exs b/test/mob/device_test.exs index 039ce991..903ae42f 100644 --- a/test/mob/device_test.exs +++ b/test/mob/device_test.exs @@ -14,10 +14,7 @@ defmodule Mob.DeviceTest do start_supervised!({Mob.Device.Android, []}) {:ok, pid} = - case GenServer.start_link(Device, [], name: :"device_#{System.unique_integer([:positive])}") do - {:ok, p} -> {:ok, p} - other -> other - end + GenServer.start_link(Device, [], name: :"device_#{System.unique_integer([:positive])}") on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) From 301e90b718ee932ff421244e08a28653ae9e319e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 19:11:19 -0600 Subject: [PATCH 054/254] =?UTF-8?q?AGENTS.md:=20add=20"Don't=20write=20thi?= =?UTF-8?q?s=20slop"=20=E2=80=94=20write-time=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ecaf01ca..7dfc02d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,85 @@ These are the things we've burned ourselves on. Following them isn't optional. - **Don't add features beyond what was requested.** A bug fix doesn't need surrounding cleanup; a one-shot doesn't need a helper. +## Don't write this slop + +LLMs reach for the same anti-patterns over and over. The list below is the +shape of code our `mix credo --strict` (via `ex_slop`) refuses to merge — but +catching it post-hoc costs a round-trip. Don't write it in the first place. + +**Error handling** +- No blanket `rescue _ -> nil` or `rescue _e -> {:error, "..."}`. Rescue the + specific exception or let it crash. +- No `rescue e -> Logger.error(...); :error` — that logs the bug into oblivion. + Either reraise or return a typed error tuple the caller can match on. +- No `try/rescue` around functions that don't raise (`Map.get`, `Enum.find`, + `String.split`). Look up whether the function actually raises before wrapping it. + +**Database access** +- Filter in SQL, not in Elixir: `from(u in User, where: u.active)` — + not `Repo.all(User) |> Enum.filter(& &1.active)`. +- No N+1 in `Enum.map`: don't `Enum.map(ids, &Repo.get(...))`. Use `Repo.all(from … where: id in ^ids)`. +- Don't write a GenServer whose entire job is `Map.get`/`Map.put` on state — + use ETS, Agent, or a struct passed by value. + +**Maps** +- Pick one key type per map. Don't `Map.get(m, :key) || Map.get(m, "key")` — + normalize once at the boundary. +- Iterate the map directly. Not `Map.keys(m) |> Enum.map(fn k -> m[k] end)`. + +**Enum / list idioms** — use the function that exists: +- `Enum.reject(&is_nil/1)` not `Enum.filter(&(&1 != nil))` +- `Enum.empty?(x)` not `length(x) == 0` +- `List.last(x)` / `Enum.at(x, -1)` not `Enum.at(x, length(x) - 1)` +- `Map.new/2` not `Enum.reduce(%{}, fn ..., &Map.put/3)` +- `Enum.into(list, %{})` only if you actually have a Collectable target; + for a plain literal target it's just `Map.new`. +- `Enum.filter` not `Enum.flat_map(fn x -> if cond, do: [x], else: [] end)` +- `Enum.sum` not a hand-rolled reduce with `+` +- `Enum.max` / `Kernel.max` not `if a > b, do: a, else: b` +- `Enum.sort(list, :desc)` not `Enum.sort(list) |> Enum.reverse()` +- `Enum.min(list)` not `Enum.sort(list) |> Enum.at(0)` +- `Enum.map_join(list, sep, &f/1)` not `Enum.map(list, &f/1) |> Enum.join(sep)` + +**`with` blocks** +- No identity `else` clause. `with :ok <- foo() do :ok end` — drop the + `else err -> err` part. + +**Strings** +- `String.length(s)` not `length(String.graphemes(s))`. +- For counting specific ASCII chars, prefer `:binary.matches/2` over graphemes. +- No manual string reverse via graphemes + reverse + join — use `String.reverse/1`. + +**Paths** +- `Application.app_dir(:my_app, "priv/...")` over `Path.expand("...priv...", __DIR__)`. + The Mix-task code in `mob_dev` is an exception — it needs cwd-relative paths + for the *user's* project. + +**Docs and comments** +- No "This module provides functionality for..." moduledoc. State *why* it + exists or what's surprising; if there's nothing to say, omit it. +- No obvious comments (`# Fetch the user` above `Repo.get(User, id)`). +- No narrator comments (`# We need to...`, `# Here we...`). +- No step comments (`# Step 1: Do X`, `# Step 2: Do Y`) — function names cover that. +- No `@doc false` on a `defp` — private already means undocumented. +- Boilerplate `## Parameters / ## Returns` sections are noise unless the + parameters are non-obvious. + +**Code shape** +- Don't shadow `Kernel` functions with local variables named `length`, `min`, + `max`, `node`, etc. +- Don't rebind a parameter inside the function body. Pick a new name. +- Don't write `x = foo(); x` at the end of a function — just `foo()`. +- Don't extract `[a, b] = list` only to immediately rebuild `[a, b]`. +- Use the same name for the same parameter across all clauses of a function. + +> **Periodic check:** `ex_slop` and the related (but heavier) [`credence`](https://hex.pm/packages/credence) +> linter add new AI-pattern checks regularly. Both ecosystems are young — +> when something here feels stale or you spot a new ExSlop release, skim +> the changelogs and update this section. Credence has ~70 rules ExSlop +> doesn't port yet; if any get backported (or if `credence` becomes worth +> wiring in alongside Credo), revisit `mob/CLAUDE.md` and the deps lists. + ## Keep this file up to date The next agent's first decision will be informed by this file. Stale guidance From 69acd76f3112834a42a0d0824a6d084dcaec6877 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Wed, 13 May 2026 19:29:20 -0600 Subject: [PATCH 055/254] =?UTF-8?q?bump:=20erlang=2029.0-rc3=20=E2=86=92?= =?UTF-8?q?=2029.0,=20elixir=201.20.0-rc.4=20=E2=86=92=201.20.0-rc.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .tool-versions | 4 ++-- crypto_plan.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.tool-versions b/.tool-versions index 66dfbbd7..25106b94 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.20.0-rc.4-otp-29 -erlang 29.0-rc3 +elixir 1.20.0-rc.5-otp-29 +erlang 29.0 zig 0.17.0-dev.269+ebff43698 diff --git a/crypto_plan.md b/crypto_plan.md index 77a31f34..13b9fc38 100644 --- a/crypto_plan.md +++ b/crypto_plan.md @@ -411,4 +411,4 @@ sync. Bump in one commit. | How is OTP cross-compiled per target? | `~/code/mob_dev/build_release.md` | | Where does the `@otp_hash` get bumped? | `~/code/mob_dev/lib/mob_dev/otp_downloader.ex` | | What does the per-project `ios/build.sh` template look like? | `~/code/mob_new/priv/templates/mob.new/ios/build.sh.eex` (or wherever the template lives) | -| Where is the OTP source tree? | `~/code/otp` (currently at `OTP-29.0-rc2-256-g73ba6e0f92`, erts-16.3) | +| Where is the OTP source tree? | `~/code/otp` (track `maint-29` for OTP 29.0+, erts-17.0+) | From 5d0a52dfbb149e6cc42c47c9c794ca21d17709d5 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 00:42:33 -0600 Subject: [PATCH 056/254] Mob.VendorUsb: port the Android NIF surface to Zig (closes mob#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/jni/mob_beam.h | 17 ++ android/jni/mob_nif.zig | 390 +++++++++++++++++++++++++++++++++++ android/jni/mob_zig.zig | 42 +++- ios/mob_nif.m | 72 +++++++ lib/mob/screen.ex | 10 + lib/mob/vendor_usb.ex | 334 ++++++++++++++++++++++++++++++ src/mob_nif.erl | 24 +++ test/mob/vendor_usb_test.exs | 132 ++++++++++++ 8 files changed, 1013 insertions(+), 8 deletions(-) create mode 100644 lib/mob/vendor_usb.ex create mode 100644 test/mob/vendor_usb_test.exs diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 86aec088..d5bfcf7d 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -5,6 +5,8 @@ #define MOB_BEAM_H #include +#include // uint8_t — for mob_deliver_vendor_usb_data +#include // size_t — ditto // Call from JNI_OnLoad (main thread). // bridge_class: e.g. "com/myapp/MobBridge" @@ -110,6 +112,21 @@ void mob_set_launch_notification(const char *json); void mob_deliver_webview_message(jlong pid, const char *json); void mob_deliver_webview_blocked(jlong pid, const char *url); +// Deliver vendor_usb (Mob.VendorUsb / USB host) events. Each builds a +// 5-tuple {:peripheral, :vendor_usb, tag, session, payload} and posts +// it to pid. devices/permission/opened carry a JSON binary, decoded +// Elixir-side by Mob.VendorUsb.normalize_message/1. session=-1 → :nil +// atom; session>=0 → integer. +void mob_deliver_vendor_usb_devices(jlong pid, const char *json_array); +void mob_deliver_vendor_usb_permission(jlong pid, int granted, const char *device_json); +void mob_deliver_vendor_usb_opened(jlong pid, int session, const char *device_json); +void mob_deliver_vendor_usb_data(jlong pid, int session, + const uint8_t *bytes, size_t nbytes); +void mob_deliver_vendor_usb_write_complete(jlong pid, int session, int bytes_written); +void mob_deliver_vendor_usb_event(jlong pid, int session, + const char *tag, // "closed" | "disconnected" | "error" + const char *reason); // atom-safe ASCII or NULL + // Deliver {:alert, action_atom} to the registered :mob_screen process. // Called from beam_jni.c when a dialog button is tapped. void mob_deliver_alert_action(const char *action); diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 72b87753..ac12cab6 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -200,6 +200,16 @@ pub const BridgeMethods = extern struct { clear_text: jni.JMethodID = null, long_press_xy: jni.JMethodID = null, swipe_xy: jni.JMethodID = null, + // ── Mob.Peripheral.VendorUsb ───────────────────────────────────────── + // Each takes a pid as jlong (so Kotlin can echo it back when calling + // mob_deliver_vendor_usb_*) plus the operation's typed payload. + vendor_usb_list_devices: jni.JMethodID = null, + vendor_usb_request_permission: jni.JMethodID = null, + vendor_usb_open: jni.JMethodID = null, + vendor_usb_bulk_write: jni.JMethodID = null, + vendor_usb_start_reading: jni.JMethodID = null, + vendor_usb_stop_reading: jni.JMethodID = null, + vendor_usb_close: jni.JMethodID = null, }; /// Exported with C ABI so mob_nif.c (and beam_jni.c for the senders in @@ -2028,6 +2038,161 @@ pub export fn mob_deliver_alert_action(action: [*:0]const u8) callconv(.c) void _ = erts.enif_send(null, &pid, env, msg); } +// ── Mob.Peripheral.VendorUsb delivery functions ────────────────────────── +// +// Six typed delivery functions, called from beam_jni.c's +// Java_..._MobBridge_nativeDeliverVendorUsb* thunks when Kotlin-side USB +// events fire (enumeration result, permission grant/deny, device opened, +// inbound chunk, write completion, lifecycle events). They build a +// 5-tuple `{:peripheral, :vendor_usb, tag, session, payload}` and post +// it to `pid`. session==-1 → atom :nil; session>=0 → integer. +// +// devices_json / permission_*_json / opened_json carry a JSON binary +// payload that the Elixir side decodes via +// `Mob.VendorUsb.normalize_message/1` (mirrors the :mob_file_result +// JSON-binary precedent for camera/photos/files/audio/scan). + +/// Session integer or :nil atom, depending on whether the Kotlin side +/// knows a session yet. +inline fn vendorUsbSessionTerm(env: ?*erts.ErlNifEnv, session: c_int) erts.ERL_NIF_TERM { + return if (session < 0) erts.atom(env, "nil") else erts.enif_make_int(env, session); +} + +pub export fn mob_deliver_vendor_usb_devices(jpid: jni.JLong, json_array: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (json_array) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (json_array) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "devices_json"), + erts.atom(env, "nil"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_permission(jpid: jni.JLong, granted: c_int, device_json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (device_json) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (device_json) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const tag = if (granted != 0) + erts.atom(env, "permission_granted_json") + else + erts.atom(env, "permission_denied_json"); + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + tag, + erts.atom(env, "nil"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_opened(jpid: jni.JLong, session: c_int, device_json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (device_json) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (device_json) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "opened_json"), + vendorUsbSessionTerm(env, session), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_data(jpid: jni.JLong, session: c_int, bytes: ?[*]const u8, nbytes: usize) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + var db: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(nbytes, &db); + if (nbytes > 0) { + if (bytes) |p| @memcpy(db.data[0..nbytes], p[0..nbytes]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "data"), + vendorUsbSessionTerm(env, session), + erts.enif_make_binary(env, &db), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_write_complete(jpid: jni.JLong, session: c_int, bytes_written: c_int) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const keys = [_]erts.ERL_NIF_TERM{erts.atom(env, "bytes")}; + const vals = [_]erts.ERL_NIF_TERM{erts.enif_make_int(env, bytes_written)}; + const map = erts.makeMap(env, &keys, &vals) orelse return; + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "write_complete"), + vendorUsbSessionTerm(env, session), + map, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_event(jpid: jni.JLong, session: c_int, tag: ?[*:0]const u8, reason: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const payload = if (reason) |r| + erts.enif_make_atom(env, r) + else + erts.atom(env, "ok"); + const tag_term = if (tag) |t| + erts.enif_make_atom(env, t) + else + erts.atom(env, "error"); + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + tag_term, + vendorUsbSessionTerm(env, session), + payload, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + // ── Capability NIFs (thin shims to Kotlin) ─────────────────────────────── export fn nif_request_permission( @@ -2713,6 +2878,208 @@ export fn nif_device_model( return erts.enif_make_string(env, "Android", erts.ERL_NIF_LATIN1); } +// ── Mob.Peripheral.VendorUsb NIFs ──────────────────────────────────────── +// +// Thin wrappers over MobBridge's @JvmStatic vendor_usb_* methods. Each +// runs on the caller's BEAM scheduler, dispatches to Kotlin via the +// cached jmethodID, and returns :ok. Results (devices listed, permission +// granted, read chunks, etc.) flow back asynchronously via the +// mob_deliver_vendor_usb_* exports above, which Kotlin invokes from its +// USB receiver / reader thread through the beam_jni.c thunks. +// +// bulk_write is marked DIRTY_IO in the NIF table because it does a +// blocking copy of up to 16 KiB into a Java byte[] + a synchronous +// Kotlin static call that ends up in UsbDeviceConnection.bulkTransfer. + +/// Sentinel-style guard: if `method` is null (MobBridge.kt doesn't have +/// the matching vendor_usb_* @JvmStatic), send a single +/// `{:peripheral, :vendor_usb, :error, nil, :unsupported}` to the caller +/// and short-circuit the NIF with :ok. Mirrors the iOS stub behaviour so +/// downstream code paths look the same whether the user is on iOS or an +/// Android app generated from an older mob_new template. +fn vendorUsbUnsupported(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + const msg_env = erts.enif_alloc_env() orelse return erts.ok(env); + defer erts.enif_free_env(msg_env); + const msg = erts.makeTuple(msg_env, .{ + erts.atom(msg_env, "peripheral"), + erts.atom(msg_env, "vendor_usb"), + erts.atom(msg_env, "error"), + erts.atom(msg_env, "nil"), + erts.atom(msg_env, "unsupported"), + }); + _ = erts.enif_send(null, &pid, msg_env, msg); + return erts.ok(env); +} + +export fn nif_vendor_usb_list_devices( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_list_devices == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_list_devices, pid, json); +} + +export fn nif_vendor_usb_request_permission( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_request_permission == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const ref = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(ref); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_request_permission, pid, ref); +} + +export fn nif_vendor_usb_open( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_open == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_open, pid, json); +} + +export fn nif_vendor_usb_bulk_write( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_bulk_write == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + var timeout_ms: c_int = 1000; + if (erts.enif_get_int(env, argv[2], &timeout_ms) == 0) return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + // Copy the bytes into a fresh Java `byte[]` so Kotlin can hand it to + // UsbDeviceConnection.bulkTransfer without re-resolving the BEAM + // binary. SetByteArrayRegion is a straight memcpy; the byte[] + // outlives this NIF call but Kotlin will let it GC once the bulk + // transfer returns. + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + // BEAM stores binary contents as unsigned bytes; JNI's byte[] is + // signed (jbyte = int8_t). A bit-for-bit copy is fine — the + // signed/unsigned distinction is irrelevant for bulk I/O bytes. + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_bulk_write, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + @as(jni.JInt, timeout_ms), + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + +export fn nif_vendor_usb_start_reading( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_start_reading == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var chunk_bytes: c_int = 4096; + if (erts.enif_get_int(env, argv[1], &chunk_bytes) == 0) return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_start_reading, + pidToJlong(pid), + @as(jni.JInt, session), + @as(jni.JInt, chunk_bytes), + ); + return erts.ok(env); +} + +export fn nif_vendor_usb_stop_reading( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_stop_reading == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_stop_reading, + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_vendor_usb_close( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_close == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_close, + @as(jni.JInt, session), + ); + return erts.ok(env); +} + // ── nif_load: cache all method IDs at BEAM startup ─────────────────────── /// Required-method helper. Returns false if the method isn't on the @@ -2812,6 +3179,21 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c if (!cacheRequired(jenv, "background_keep_alive", "()V", &Bridge.background_keep_alive)) return -1; if (!cacheRequired(jenv, "background_stop", "()V", &Bridge.background_stop)) return -1; + // Mob.Peripheral.VendorUsb. Optional rather than required so apps + // generated from an older `mob_new` template (without the matching + // MobBridge.kt vendor_usb block from mob_new#2) still load — every + // vendor_usb NIF below short-circuits with `:unsupported` when the + // matching methodID is null. The user-visible effect on a stale + // app is "call returns :ok but you get a single :error event with + // reason :unsupported", which mirrors the iOS stubs' behaviour. + cacheOptional(jenv, "vendor_usb_list_devices", "(JLjava/lang/String;)V", &Bridge.vendor_usb_list_devices); + cacheOptional(jenv, "vendor_usb_request_permission", "(JLjava/lang/String;)V", &Bridge.vendor_usb_request_permission); + cacheOptional(jenv, "vendor_usb_open", "(JLjava/lang/String;)V", &Bridge.vendor_usb_open); + cacheOptional(jenv, "vendor_usb_bulk_write", "(JI[BI)V", &Bridge.vendor_usb_bulk_write); + cacheOptional(jenv, "vendor_usb_start_reading", "(JII)V", &Bridge.vendor_usb_start_reading); + cacheOptional(jenv, "vendor_usb_stop_reading", "(I)V", &Bridge.vendor_usb_stop_reading); + cacheOptional(jenv, "vendor_usb_close", "(I)V", &Bridge.vendor_usb_close); + g_launch_notif_mutex = erts.enif_mutex_create("mob_launch_notif_mutex"); if (g_launch_notif_mutex == null) { loge_nif("nif_load: failed to create launch notif mutex", .{}); @@ -2921,6 +3303,14 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "device_foreground", .arity = 0, .fptr = nif_device_foreground, .flags = 0 }, .{ .name = "device_os_version", .arity = 0, .fptr = nif_device_os_version, .flags = 0 }, .{ .name = "device_model", .arity = 0, .fptr = nif_device_model, .flags = 0 }, + // ── Mob.Peripheral.VendorUsb (Android USB host) ────────────────────────── + .{ .name = "vendor_usb_list_devices", .arity = 1, .fptr = nif_vendor_usb_list_devices, .flags = 0 }, + .{ .name = "vendor_usb_request_permission", .arity = 1, .fptr = nif_vendor_usb_request_permission, .flags = 0 }, + .{ .name = "vendor_usb_open", .arity = 1, .fptr = nif_vendor_usb_open, .flags = 0 }, + .{ .name = "vendor_usb_bulk_write", .arity = 3, .fptr = nif_vendor_usb_bulk_write, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "vendor_usb_start_reading", .arity = 2, .fptr = nif_vendor_usb_start_reading, .flags = 0 }, + .{ .name = "vendor_usb_stop_reading", .arity = 1, .fptr = nif_vendor_usb_stop_reading, .flags = 0 }, + .{ .name = "vendor_usb_close", .arity = 1, .fptr = nif_vendor_usb_close, .flags = 0 }, }; var mob_nif_entry: erts.ErlNifEntry = .{ diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig index dd375efc..9e47a9f6 100644 --- a/android/jni/mob_zig.zig +++ b/android/jni/mob_zig.zig @@ -158,10 +158,16 @@ pub const JNI_VERSION_1_6: c_int = 0x00010006; pub const JNI_OK: c_int = 0; pub const JBoolean = u8; +pub const JByte = i8; pub const JInt = i32; pub const JLong = i64; pub const JFloat = f32; pub const JDouble = f64; +/// `jsize` is a typedef alias for `jint` in jni.h; keep them as distinct +/// names here so the byte-array helpers below read like the JNI signatures +/// they wrap. +pub const JSize = JInt; +pub const JByteArray = JObject; pub const JObject = ?*anyopaque; pub const JClass = JObject; @@ -392,12 +398,16 @@ pub const JNINativeInterface = extern struct { // 171: GetArrayLength — typed (used by nif_screen_info). GetArrayLength: ?*const fn (env: *JNIEnv, arr: JObject) callconv(.c) JInt, - // 172-178: ObjectArray + primitive-array constructors — unused. + // 172-178: ObjectArray + primitive-array constructors. NewByteArray is + // typed because nif_vendor_usb_bulk_write needs it (Mob.VendorUsb's + // raw-USB write path hands an iolist→binary across the JNI boundary + // as a `byte[]`). The others stay opaque until something else needs + // them. NewObjectArray: ?*anyopaque, GetObjectArrayElement: ?*anyopaque, SetObjectArrayElement: ?*anyopaque, NewBooleanArray: ?*anyopaque, - NewByteArray: ?*anyopaque, + NewByteArray: ?*const fn (env: *JNIEnv, len: JSize) callconv(.c) JByteArray, NewCharArray: ?*anyopaque, NewShortArray: ?*anyopaque, @@ -434,12 +444,20 @@ pub const JNINativeInterface = extern struct { GetIntArrayRegion: ?*anyopaque, GetLongArrayRegion: ?*anyopaque, GetFloatArrayRegion: ?*const fn (env: *JNIEnv, arr: JObject, start: JInt, len: JInt, buf: [*]f32) callconv(.c) void, - - // The remaining ~30 slots (Get/Set*ArrayRegion tail, RegisterNatives, - // MonitorEnter/Exit, GetJavaVM, NewWeakGlobalRef, ExceptionCheck, - // DirectByteBuffer ops, GetObjectRefType) are not used by mob_nif.zig - // today. Add when a later iter needs them — the rule is "match jni.h - // up to the last USED slot". + GetDoubleArrayRegion: ?*anyopaque, + + // 204-211: SetXxxArrayRegion. SetByteArrayRegion is typed because + // nif_vendor_usb_bulk_write copies BEAM-side bytes into a fresh + // `byte[]` via NewByteArray + SetByteArrayRegion before the static + // method call. + SetBooleanArrayRegion: ?*anyopaque, + SetByteArrayRegion: ?*const fn (env: *JNIEnv, arr: JByteArray, start: JSize, len: JSize, buf: [*]const JByte) callconv(.c) void, + + // The remaining ~25 slots (Set*ArrayRegion tail past byte, + // RegisterNatives, MonitorEnter/Exit, GetJavaVM, NewWeakGlobalRef, + // ExceptionCheck, DirectByteBuffer ops, GetObjectRefType) are not + // used by mob_nif.zig today. Add when a later iter needs them — the + // rule is "match jni.h up to the last USED slot". }; /// JavaVM vtable — used for GetEnv / AttachCurrentThread / DetachCurrentThread. @@ -527,6 +545,14 @@ pub inline fn getFloatArrayRegion(env: *JNIEnv, arr: JObject, start: JInt, len: env.*.GetFloatArrayRegion.?(env, arr, start, len, buf); } +pub inline fn newByteArray(env: *JNIEnv, len: JSize) JByteArray { + return env.*.NewByteArray.?(env, len); +} + +pub inline fn setByteArrayRegion(env: *JNIEnv, arr: JByteArray, start: JSize, len: JSize, buf: [*]const JByte) void { + env.*.SetByteArrayRegion.?(env, arr, start, len, buf); +} + pub inline fn getEnv(vm: *JavaVM, version: JInt) ?*JNIEnv { var env: ?*anyopaque = null; if (vm.*.GetEnv.?(vm, &env, version) != JNI_OK) return null; diff --git a/ios/mob_nif.m b/ios/mob_nif.m index fa22883b..cf625aa3 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5627,6 +5627,70 @@ void mob_send_component_event(int handle, const char *event, const char *payload enif_free_env(env); } +// ── Mob.Peripheral.VendorUsb (iOS stubs) ────────────────────────────────────── +// +// iOS exposes no public USB-host API equivalent to Android's UsbManager. +// All seven NIFs below send {:peripheral, :vendor_usb, :error, nil, :unsupported} +// back to the caller and return :ok. Cross-platform screens see the error +// event and degrade gracefully via Mob.Peripheral.capabilities/0. + +static void send_vendor_usb_unsupported(ErlNifPid pid) { + ErlNifEnv* e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), + enif_make_atom(e, "unsupported")); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; (void)argv; + return enif_make_atom(env, "ok"); +} + // Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a @@ -5732,6 +5796,14 @@ void mob_send_component_event(int handle, const char *event, const char *payload {"webview_go_back", 0, nif_webview_go_back, 0}, {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, + // ── Mob.Peripheral.VendorUsb (iOS stubs — emit :unsupported) ────────────── + {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, + {"vendor_usb_request_permission", 1, nif_vendor_usb_request_permission, 0}, + {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, + {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, 0}, + {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, + {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, + {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, // getaddrinfo can block on the resolver for seconds — dirty-IO so it // doesn't head-of-line-block the regular schedulers. See the impl // above for the iOS rationale. diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 45ca0dc3..1bd138c7 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -384,6 +384,16 @@ defmodule Mob.Screen do handle_info(msg, state) end + # Peripheral.* events: a few carry JSON-encoded device records under tags + # like `:devices_json`, `:permission_granted_json`, etc. The transport's + # own module knows how to decode them; we dispatch through its + # `normalize_message/1` (a no-op for events without JSON payloads) before + # the user's handle_info sees them. + def handle_info({:peripheral, :vendor_usb, _tag, _session, _payload} = msg, state) do + normalized = Mob.VendorUsb.normalize_message(msg) + handle_info(normalized, state) + end + # System back gesture (Android hardware/swipe, iOS edge-pan). # Handled here — before the user's handle_info — so every screen gets back # navigation for free without implementing anything. diff --git a/lib/mob/vendor_usb.ex b/lib/mob/vendor_usb.ex new file mode 100644 index 00000000..78d9b103 --- /dev/null +++ b/lib/mob/vendor_usb.ex @@ -0,0 +1,334 @@ +defmodule Mob.VendorUsb do + @moduledoc """ + Raw USB host access via vendor bulk endpoints. **Android only.** + + No permission required at the OS-permission level, but Android prompts the + user to grant per-device access via the system dialog when you call + `request_permission/2`. The grant is per app + device + session; granting + "always" only sticks if the user ticks the checkbox. + + iOS calls return the socket unchanged and emit + `{:peripheral, :vendor_usb, :error, nil, :unsupported}`. See + `Mob.Ble` for iOS-friendly equivalent transports. + the (forthcoming) `Mob.Midi` or `Mob.Ble`. + + ## Lifecycle + + ``` + list_devices/1 → {:peripheral, :vendor_usb, :devices, _, [device, …]} + request_permission/2 → {:peripheral, :vendor_usb, :permission_granted, _, device} + {:peripheral, :vendor_usb, :permission_denied, _, device} + open/2 → {:peripheral, :vendor_usb, :opened, session, device} + {:peripheral, :vendor_usb, :error, nil, reason} + bulk_write/4 → {:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}} + (or :error for failures) + start_reading/3 → {:peripheral, :vendor_usb, :data, session, binary} + (delivered repeatedly; use stop_reading/2 to halt) + stop_reading/2 + close/2 → {:peripheral, :vendor_usb, :closed, session, reason} + ``` + + Any unsolicited `{:peripheral, :vendor_usb, :disconnected, session, reason}` + may arrive at any time (cable unplug, device removed). After + `:disconnected`, the session handle is dead — drop your reference and call + `list_devices/1` again to reacquire. + + ## Example: a USB echo demo + + This shape works for any USB device that exposes bulk IN/OUT + endpoints. Substitute the VID/PID and frame format for your device. + + defmodule MyApp.UsbScreen do + use Mob.Screen + alias Mob.VendorUsb + + @my_vid 0x1234 + @my_pid 0x5678 + + def mount(_p, _s, socket) do + {:ok, + socket + |> Mob.Socket.assign(:devices, []) + |> Mob.Socket.assign(:session, nil) + |> VendorUsb.list_devices(vendor_id: @my_vid)} + end + + def handle_info({:peripheral, :vendor_usb, :devices, _, devices}, socket) do + {:noreply, Mob.Socket.assign(socket, :devices, devices)} + end + + def handle_info({:peripheral, :vendor_usb, :permission_granted, _, dev}, socket) do + {:noreply, VendorUsb.open(socket, dev, interface: 0)} + end + + def handle_info({:peripheral, :vendor_usb, :opened, session, _dev}, socket) do + socket = + socket + |> Mob.Socket.assign(:session, session) + |> VendorUsb.start_reading(session) + |> VendorUsb.bulk_write(session, "hello") + + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :data, _session, binary}, socket) do + IO.inspect(binary, label: "from device") + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :disconnected, _, _}, socket) do + {:noreply, Mob.Socket.assign(socket, :session, nil)} + end + end + + ## Framing is your problem + + This module is byte-level. USB bulk endpoints do *not* preserve message + boundaries — the bytes you wrote in one `bulk_write/4` call may arrive + on the other end split across multiple chunks, or coalesced with later + writes. Likewise, `:data` events deliver whatever the OS happens to + hand back from a read; do not assume one event corresponds to one + logical message. + + If your device uses a framed protocol (length-prefix, COBS, SLIP, + delimiters, fixed-size records), implement the framer in a layer + above this one. A reasonable pattern is a `GenServer` that owns the + session, accumulates incoming chunks into a buffer, and drains + complete frames out for higher-level consumers. + + ## Device shape + + Devices arrive as maps: + + %{ + vendor_id: 0x1234, + product_id: 0x5678, + manufacturer: "Acme Inc.", + product: "Widget 9000", + serial: "SN-000001", + # opaque handle the OS uses to refer to this device. Treat as a + # binary; do not parse. Pass back to `request_permission/2` etc. + ref: "/dev/bus/usb/001/002" + } + + ## Session handles + + `open/2` delivers an integer session handle. Session handles are valid + until `:disconnected` or `close/2`. They are *not* persistent across app + restarts — re-enumerate after launch. + + ## Buffer ownership + + Binaries you pass to `bulk_write/4` are copied into a native-side buffer + before the NIF returns. Binaries delivered via `:data` are owned by the + BEAM — they will outlive the underlying USB read buffer. + + ## Limits + + Maximum write size per call: 16 KiB. Larger writes are rejected with + `{:error, :payload_too_large}`. Read chunks are bounded by the USB max + packet size for the endpoint (typically 64 B Full Speed, 512 B High + Speed); the native read loop coalesces packets into BEAM-side binaries + bounded by `:read_chunk_bytes` (default 4 KiB). + """ + + @type device :: %{ + vendor_id: non_neg_integer(), + product_id: non_neg_integer(), + manufacturer: String.t() | nil, + product: String.t() | nil, + serial: String.t() | nil, + ref: String.t() + } + + @type session :: integer() + + @max_write_bytes 16 * 1024 + + @doc """ + Enumerate connected USB devices. + + Result: `{:peripheral, :vendor_usb, :devices, nil, [device, …]}` + + Options: + * `:vendor_id` — filter to a single VID + * `:product_id` — filter to a single PID (only meaningful with VID) + + Filtering happens native-side; an empty result is a real "no matching + device", not a permission/availability issue. + """ + @spec list_devices(Mob.Socket.t(), keyword()) :: Mob.Socket.t() + def list_devices(socket, opts \\ []) do + filter = + %{} + |> maybe_put_filter("vendor_id", Keyword.get(opts, :vendor_id)) + |> maybe_put_filter("product_id", Keyword.get(opts, :product_id)) + + json = :json.encode(filter) + :mob_nif.vendor_usb_list_devices(json) + socket + end + + defp maybe_put_filter(map, _key, nil), do: map + defp maybe_put_filter(map, key, val), do: Map.put(map, key, val) + + @doc """ + Ask the OS to prompt the user to grant access to a specific device. + + `device` is the map returned by `list_devices/1`. Only the `:ref` field + is consulted, but it is convenient to pass the whole map. + + Result: + * `{:peripheral, :vendor_usb, :permission_granted, nil, device}` + * `{:peripheral, :vendor_usb, :permission_denied, nil, device}` + + Idempotent. If the user has already granted access, the granted message + fires immediately without showing a dialog. + """ + @spec request_permission(Mob.Socket.t(), device()) :: Mob.Socket.t() + def request_permission(socket, %{ref: ref} = _device) when is_binary(ref) do + :mob_nif.vendor_usb_request_permission(ref) + socket + end + + @doc """ + Open a permitted device and claim an interface. + + Options: + * `:interface` — interface number (default `0`) + * `:endpoint_in` — bulk IN endpoint address (e.g. `0x81`); if omitted, + the first bulk IN endpoint on the interface is auto-selected + * `:endpoint_out` — bulk OUT endpoint address (e.g. `0x01`); if + omitted, the first bulk OUT endpoint on the interface is + auto-selected + + Result: + * `{:peripheral, :vendor_usb, :opened, session, device}` + * `{:peripheral, :vendor_usb, :error, nil, reason}` — common reasons: + `:no_permission`, `:device_gone`, `:interface_busy`, + `:no_bulk_endpoints` + """ + @spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t() + def open(socket, %{ref: ref}, opts \\ []) when is_binary(ref) do + fields = + %{"ref" => ref, "interface" => Keyword.get(opts, :interface, 0)} + |> maybe_put_filter("endpoint_in", Keyword.get(opts, :endpoint_in)) + |> maybe_put_filter("endpoint_out", Keyword.get(opts, :endpoint_out)) + + json = :json.encode(fields) + :mob_nif.vendor_usb_open(json) + socket + end + + @doc """ + Send bytes to the device's bulk OUT endpoint. + + `data` may be a binary or iolist; it is flattened and copied native-side + before the NIF returns. Maximum size: #{@max_write_bytes} bytes. + + Options: + * `:timeout_ms` — write timeout (default `1000`) + + Result: + * `{:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}` + * `{:peripheral, :vendor_usb, :error, session, reason}` + """ + @spec bulk_write(Mob.Socket.t(), session(), iodata(), keyword()) :: Mob.Socket.t() + def bulk_write(socket, session, data, opts \\ []) when is_integer(session) do + bin = IO.iodata_to_binary(data) + + cond do + byte_size(bin) == 0 -> + socket + + byte_size(bin) > @max_write_bytes -> + send(self(), {:peripheral, :vendor_usb, :error, session, :payload_too_large}) + socket + + true -> + timeout = Keyword.get(opts, :timeout_ms, 1000) + :mob_nif.vendor_usb_bulk_write(session, bin, timeout) + socket + end + end + + @doc """ + Start a continuous read loop on the bulk IN endpoint. + + After this call, every chunk read native-side is delivered as + `{:peripheral, :vendor_usb, :data, session, binary}` to the calling + process. Stop with `stop_reading/2`. + + Options: + * `:read_chunk_bytes` — soft cap on per-message coalescing (default + `4096`). Smaller values reduce latency; larger reduce overhead. + + Idempotent: calling twice is a no-op. + """ + @spec start_reading(Mob.Socket.t(), session(), keyword()) :: Mob.Socket.t() + def start_reading(socket, session, opts \\ []) when is_integer(session) do + chunk = Keyword.get(opts, :read_chunk_bytes, 4096) + :mob_nif.vendor_usb_start_reading(session, chunk) + socket + end + + @doc "Stop the read loop started by `start_reading/3`." + @spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t() + def stop_reading(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_stop_reading(session) + socket + end + + @doc """ + Close a device session, releasing the interface and freeing the file + descriptor. Idempotent. Always emits + `{:peripheral, :vendor_usb, :closed, session, :ok}`. + """ + @spec close(Mob.Socket.t(), session()) :: Mob.Socket.t() + def close(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_close(session) + socket + end + + # ── Event normalization ──────────────────────────────────────────────── + # + # The Android NIF delivers a few high-cardinality events with their + # payloads as JSON binaries (`:devices_json`, `:permission_granted_json`, + # `:permission_denied_json`, `:opened_json`) to keep the C/JNI side + # simple. `Mob.Screen` calls `normalize_message/1` once before the + # screen's `handle_info/2` runs, so user code only sees the public event + # shape documented at the top of this module. + + @doc false + @spec normalize_message(term()) :: term() + def normalize_message({:peripheral, :vendor_usb, :devices_json, _, json}) + when is_binary(json) do + devices = json |> :json.decode() |> Enum.map(&device_from_map/1) + {:peripheral, :vendor_usb, :devices, nil, devices} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_granted_json, _, json}) do + {:peripheral, :vendor_usb, :permission_granted, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_denied_json, _, json}) do + {:peripheral, :vendor_usb, :permission_denied, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :opened_json, session, json}) do + {:peripheral, :vendor_usb, :opened, session, device_from_map(:json.decode(json))} + end + + def normalize_message(other), do: other + + defp device_from_map(map) when is_map(map) do + %{ + vendor_id: Map.get(map, "vendor_id"), + product_id: Map.get(map, "product_id"), + manufacturer: Map.get(map, "manufacturer"), + product: Map.get(map, "product"), + serial: Map.get(map, "serial"), + ref: Map.get(map, "ref") + } + end +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index d3a34f4e..722932d2 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -96,6 +96,14 @@ clear_text/0, long_press_xy/3, swipe_xy/4, + %% Peripheral.VendorUsb (Android USB host; iOS returns :unsupported) + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1, %% DNS — see Mob.DNS and guides/dns_on_ios.md resolve_ipv4/1 ]). @@ -180,6 +188,14 @@ %% Native view components register_component/1, deregister_component/1, + %% Peripheral.VendorUsb + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1, %% DNS — in-process getaddrinfo so iOS apps bypass BEAM's %% broken inet_gethost path. See `Mob.DNS` for the Elixir %% wrapper and `guides/dns_on_ios.md` for the why. @@ -265,4 +281,12 @@ webview_can_go_back() -> erlang:nif_error(not_loaded). webview_go_back() -> erlang:nif_error(not_loaded). register_component(_Pid) -> erlang:nif_error(not_loaded). deregister_component(_Handle) -> erlang:nif_error(not_loaded). +%% Peripheral.VendorUsb +vendor_usb_list_devices(_FilterJson) -> erlang:nif_error(not_loaded). +vendor_usb_request_permission(_Ref) -> erlang:nif_error(not_loaded). +vendor_usb_open(_OptsJson) -> erlang:nif_error(not_loaded). +vendor_usb_bulk_write(_Session, _Bytes, _TimeoutMs) -> erlang:nif_error(not_loaded). +vendor_usb_start_reading(_Session, _ChunkBytes) -> erlang:nif_error(not_loaded). +vendor_usb_stop_reading(_Session) -> erlang:nif_error(not_loaded). +vendor_usb_close(_Session) -> erlang:nif_error(not_loaded). resolve_ipv4(_Host) -> erlang:nif_error(not_loaded). diff --git a/test/mob/vendor_usb_test.exs b/test/mob/vendor_usb_test.exs new file mode 100644 index 00000000..f937d787 --- /dev/null +++ b/test/mob/vendor_usb_test.exs @@ -0,0 +1,132 @@ +defmodule Mob.VendorUsbTest do + use ExUnit.Case, async: true + + alias Mob.VendorUsb + + describe "normalize_message/1 — devices_json" do + test "decodes a list of device records" do + json = + IO.iodata_to_binary( + :json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "manufacturer" => "Acme Inc.", + "product" => "Widget 9000", + "serial" => "SN-000001", + "ref" => "/dev/bus/usb/001/002" + } + ]) + ) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :devices_json, nil, json}) + + assert device.vendor_id == 0x1234 + assert device.product_id == 0x5678 + assert device.manufacturer == "Acme Inc." + assert device.product == "Widget 9000" + assert device.serial == "SN-000001" + assert device.ref == "/dev/bus/usb/001/002" + end + + test "empty list passes through cleanly" do + assert {:peripheral, :vendor_usb, :devices, nil, []} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :devices_json, nil, + IO.iodata_to_binary(:json.encode([]))} + ) + end + + test "tolerates missing optional string fields" do + json = + IO.iodata_to_binary( + :json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + } + ]) + ) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :devices_json, nil, json}) + + assert device.manufacturer == nil + assert device.product == nil + assert device.serial == nil + end + end + + describe "normalize_message/1 — permission events" do + test "permission_granted_json becomes :permission_granted with a device map" do + json = + IO.iodata_to_binary( + :json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + }) + ) + + assert {:peripheral, :vendor_usb, :permission_granted, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_granted_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + + test "permission_denied_json becomes :permission_denied" do + json = IO.iodata_to_binary(:json.encode(%{"ref" => "/dev/bus/usb/001/002"})) + + assert {:peripheral, :vendor_usb, :permission_denied, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_denied_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + end + + describe "normalize_message/1 — opened_json" do + test "decodes opened with session id and device payload" do + json = + IO.iodata_to_binary( + :json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + }) + ) + + assert {:peripheral, :vendor_usb, :opened, 7, device} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :opened_json, 7, json}) + + assert device.vendor_id == 0x1234 + end + end + + describe "normalize_message/1 — passthrough" do + test "non-JSON peripheral events pass through unchanged" do + msg = {:peripheral, :vendor_usb, :data, 7, <<1, 2, 3>>} + assert VendorUsb.normalize_message(msg) == msg + end + + test "write_complete passes through unchanged" do + msg = {:peripheral, :vendor_usb, :write_complete, 7, %{bytes: 4}} + assert VendorUsb.normalize_message(msg) == msg + end + + test "error event passes through unchanged" do + msg = {:peripheral, :vendor_usb, :error, 7, :write_timeout} + assert VendorUsb.normalize_message(msg) == msg + end + + test "unrelated messages pass through unchanged" do + msg = {:something, :else} + assert VendorUsb.normalize_message(msg) == msg + end + end +end From 3016bb7d8f97d9f6e4dc88f843d1cdc77204df35 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 00:42:33 -0600 Subject: [PATCH 057/254] lint: clang-format pass on native sources --- android/jni/mob_beam.h | 11 +- ios/MobDemo-Bridging-Header.h | 8 +- ios/MobNode.h | 203 ++++++++++++++++++---------------- ios/mob_beam.h | 10 +- ios/mob_nif.m | 77 +++++++------ 5 files changed, 165 insertions(+), 144 deletions(-) diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index d5bfcf7d..0343f86f 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -5,8 +5,8 @@ #define MOB_BEAM_H #include -#include // uint8_t — for mob_deliver_vendor_usb_data -#include // size_t — ditto +#include // size_t — ditto +#include // uint8_t — for mob_deliver_vendor_usb_data // Call from JNI_OnLoad (main thread). // bridge_class: e.g. "com/myapp/MobBridge" @@ -120,12 +120,11 @@ void mob_deliver_webview_blocked(jlong pid, const char *url); void mob_deliver_vendor_usb_devices(jlong pid, const char *json_array); void mob_deliver_vendor_usb_permission(jlong pid, int granted, const char *device_json); void mob_deliver_vendor_usb_opened(jlong pid, int session, const char *device_json); -void mob_deliver_vendor_usb_data(jlong pid, int session, - const uint8_t *bytes, size_t nbytes); +void mob_deliver_vendor_usb_data(jlong pid, int session, const uint8_t *bytes, size_t nbytes); void mob_deliver_vendor_usb_write_complete(jlong pid, int session, int bytes_written); void mob_deliver_vendor_usb_event(jlong pid, int session, - const char *tag, // "closed" | "disconnected" | "error" - const char *reason); // atom-safe ASCII or NULL + const char *tag, // "closed" | "disconnected" | "error" + const char *reason); // atom-safe ASCII or NULL // Deliver {:alert, action_atom} to the registered :mob_screen process. // Called from beam_jni.c when a dialog button is tapped. diff --git a/ios/MobDemo-Bridging-Header.h b/ios/MobDemo-Bridging-Header.h index ec6cebdc..67bd6df1 100644 --- a/ios/MobDemo-Bridging-Header.h +++ b/ios/MobDemo-Bridging-Header.h @@ -9,15 +9,15 @@ void mob_handle_back(void); // Called from MobRootView.swift WebView delegate when JS sends a message or a URL is blocked. // Implemented in mob_nif.m; looks up :mob_screen and sends the appropriate tuple. -void mob_deliver_webview_message(const char* json_utf8); -void mob_deliver_webview_blocked(const char* url_utf8); +void mob_deliver_webview_message(const char *json_utf8); +void mob_deliver_webview_blocked(const char *url_utf8); // Called from MobNativeViewRegistry.send closure when a native view fires an event. // Implemented in mob_nif.m; looks up the component pid by handle and delivers // {:component_event, event, payload_json} to it. -void mob_send_component_event(int handle, const char* event, const char* payload_json); +void mob_send_component_event(int handle, const char *event, const char *payload_json); // Called from MobRootView.swift's .onChange(of: colorScheme) modifier when // the OS appearance toggles (light/dark). Dispatches to Mob.Device subscribers. // `scheme` is "light" or "dark". -void mob_notify_color_scheme(const char* scheme); +void mob_notify_color_scheme(const char *scheme); diff --git a/ios/MobNode.h b/ios/MobNode.h index 86724a78..c4242c83 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -4,16 +4,16 @@ #pragma once -#import -#import #import +#import +#import #import // Shared camera preview session — set by nif_camera_start/stop_preview, read by MobRootView. -extern AVCaptureSession* _Nullable g_preview_session; +extern AVCaptureSession *_Nullable g_preview_session; // Shared WebView — set by MobWebView when created, read by webview NIFs. -extern WKWebView* _Nullable g_webview; +extern WKWebView *_Nullable g_webview; typedef NS_ENUM(NSInteger, MobNodeType) { MobNodeTypeColumn, @@ -44,40 +44,40 @@ NS_ASSUME_NONNULL_BEGIN @interface MobNode : NSObject // Layout -@property (nonatomic) MobNodeType nodeType; -@property (nonatomic, strong, nullable) UIColor* backgroundColor; -@property (nonatomic) CGFloat padding; // uniform; -1 if unset -@property (nonatomic) CGFloat paddingTop; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingRight; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingBottom; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingLeft; // -1 = use uniform padding +@property(nonatomic) MobNodeType nodeType; +@property(nonatomic, strong, nullable) UIColor *backgroundColor; +@property(nonatomic) CGFloat padding; // uniform; -1 if unset +@property(nonatomic) CGFloat paddingTop; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingRight; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingBottom; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingLeft; // -1 = use uniform padding // Text / Button -@property (nonatomic, copy, nullable) NSString* text; -@property (nonatomic) CGFloat textSize; -@property (nonatomic, strong, nullable) UIColor* textColor; +@property(nonatomic, copy, nullable) NSString *text; +@property(nonatomic) CGFloat textSize; +@property(nonatomic, strong, nullable) UIColor *textColor; // Tap -@property (nonatomic, copy, nullable) void (^onTap)(void); +@property(nonatomic, copy, nullable) void (^onTap)(void); // Value-bearing change callbacks (set by mob_nif.m; called by SwiftUI) -@property (nonatomic, copy, nullable) void (^onChangeStr)(NSString*); -@property (nonatomic, copy, nullable) void (^onChangeBool)(BOOL); -@property (nonatomic, copy, nullable) void (^onChangeFloat)(double); +@property(nonatomic, copy, nullable) void (^onChangeStr)(NSString *); +@property(nonatomic, copy, nullable) void (^onChangeBool)(BOOL); +@property(nonatomic, copy, nullable) void (^onChangeFloat)(double); // Selection (pickers, menus, segmented controls) -@property (nonatomic, copy, nullable) void (^onSelect)(void); +@property(nonatomic, copy, nullable) void (^onSelect)(void); // Gestures (Batch 4) — set by mob_nif.m via tap-handle registration. // SwiftUI side wires these through .onLongPressGesture, .gesture(TapGesture(count:2)), // .gesture(DragGesture(...)). Each is opt-in (nil = no gesture recognizer). -@property (nonatomic, copy, nullable) void (^onLongPress)(void); -@property (nonatomic, copy, nullable) void (^onDoubleTap)(void); -@property (nonatomic, copy, nullable) void (^onSwipe)(NSString* direction); -@property (nonatomic, copy, nullable) void (^onSwipeLeft)(void); -@property (nonatomic, copy, nullable) void (^onSwipeRight)(void); -@property (nonatomic, copy, nullable) void (^onSwipeUp)(void); -@property (nonatomic, copy, nullable) void (^onSwipeDown)(void); +@property(nonatomic, copy, nullable) void (^onLongPress)(void); +@property(nonatomic, copy, nullable) void (^onDoubleTap)(void); +@property(nonatomic, copy, nullable) void (^onSwipe)(NSString *direction); +@property(nonatomic, copy, nullable) void (^onSwipeLeft)(void); +@property(nonatomic, copy, nullable) void (^onSwipeRight)(void); +@property(nonatomic, copy, nullable) void (^onSwipeUp)(void); +@property(nonatomic, copy, nullable) void (^onSwipeDown)(void); // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ── // These callbacks are wired by mob_nif.m. Throttling and delta-thresholding @@ -86,150 +86,159 @@ NS_ASSUME_NONNULL_BEGIN // // Scroll: SwiftUI .onScrollGeometryChange (iOS 17+) or UIScrollView delegate. // (CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, CGFloat vx, CGFloat vy, NSString phase) -@property (nonatomic, copy, nullable) void (^onScroll)(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onScroll) + (CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, NSString *); // Drag: pan gesture deltas. // (CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString phase) -@property (nonatomic, copy, nullable) void (^onDrag)(CGFloat, CGFloat, CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onDrag)(CGFloat, CGFloat, CGFloat, CGFloat, NSString *); // Pinch: scale + velocity. (CGFloat scale, CGFloat velocity, NSString phase) -@property (nonatomic, copy, nullable) void (^onPinch)(CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onPinch)(CGFloat, CGFloat, NSString *); // Rotate: angle in degrees + velocity. (CGFloat degrees, CGFloat velocity, NSString phase) -@property (nonatomic, copy, nullable) void (^onRotate)(CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onRotate)(CGFloat, CGFloat, NSString *); // Pointer move (iPad trackpad / Apple Pencil hover). // (CGFloat x, CGFloat y) -@property (nonatomic, copy, nullable) void (^onPointerMove)(CGFloat, CGFloat); +@property(nonatomic, copy, nullable) void (^onPointerMove)(CGFloat, CGFloat); // ── Batch 5 Tier 2: semantic scroll events (single-fire) ── -@property (nonatomic, copy, nullable) void (^onScrollBegan)(void); -@property (nonatomic, copy, nullable) void (^onScrollEnded)(void); -@property (nonatomic, copy, nullable) void (^onScrollSettled)(void); -@property (nonatomic, copy, nullable) void (^onTopReached)(void); -@property (nonatomic, copy, nullable) void (^onScrolledPast)(void); -@property (nonatomic) CGFloat scrolledPastThreshold; // y boundary +@property(nonatomic, copy, nullable) void (^onScrollBegan)(void); +@property(nonatomic, copy, nullable) void (^onScrollEnded)(void); +@property(nonatomic, copy, nullable) void (^onScrollSettled)(void); +@property(nonatomic, copy, nullable) void (^onTopReached)(void); +@property(nonatomic, copy, nullable) void (^onScrolledPast)(void); +@property(nonatomic) CGFloat scrolledPastThreshold; // y boundary // ── Batch 5 Tier 3: native-side scroll-driven UI ── // Each is a config dict (decoded from JSON). The SwiftUI view layer reads // these and wires them up using .scrollPosition / .onScrollGeometryChange // observers without going through the BEAM. nil = not configured. -@property (nonatomic, strong, nullable) NSDictionary* parallaxConfig; -@property (nonatomic, strong, nullable) NSDictionary* fadeOnScrollConfig; -@property (nonatomic, strong, nullable) NSDictionary* stickyWhenScrolledPastConfig; +@property(nonatomic, strong, nullable) NSDictionary *parallaxConfig; +@property(nonatomic, strong, nullable) NSDictionary *fadeOnScrollConfig; +@property(nonatomic, strong, nullable) NSDictionary *stickyWhenScrolledPastConfig; // text_field -@property (nonatomic, copy, nullable) NSString* placeholder; -@property (nonatomic, copy, nonnull) NSString* keyboardTypeStr; // "default","number","decimal","email","phone","url" -@property (nonatomic, copy, nonnull) NSString* returnKeyStr; // "done","next","go","search","send" -@property (nonatomic, copy, nullable) void (^onFocus)(void); -@property (nonatomic, copy, nullable) void (^onBlur)(void); -@property (nonatomic, copy, nullable) void (^onSubmit)(void); +@property(nonatomic, copy, nullable) NSString *placeholder; +@property(nonatomic, copy, nonnull) + NSString *keyboardTypeStr; // "default","number","decimal","email","phone","url" +@property(nonatomic, copy, nonnull) NSString *returnKeyStr; // "done","next","go","search","send" +@property(nonatomic, copy, nullable) void (^onFocus)(void); +@property(nonatomic, copy, nullable) void (^onBlur)(void); +@property(nonatomic, copy, nullable) void (^onSubmit)(void); // IME composition (CJK, Korean, Vietnamese, accent input). Called by // the iOS text-input layer when marked-text state changes. // text: the in-progress (or committed) text // phase: "began" | "updating" | "committed" | "cancelled" -@property (nonatomic, copy, nullable) void (^onCompose)(NSString* text, NSString* phase); +@property(nonatomic, copy, nullable) void (^onCompose)(NSString *text, NSString *phase); // toggle -@property (nonatomic) BOOL checked; +@property(nonatomic) BOOL checked; // slider -@property (nonatomic) CGFloat minValue; // default 0.0 -@property (nonatomic) CGFloat maxValue; // default 1.0 +@property(nonatomic) CGFloat minValue; // default 0.0 +@property(nonatomic) CGFloat maxValue; // default 1.0 // Divider -@property (nonatomic) CGFloat thickness; // default 1.0 +@property(nonatomic) CGFloat thickness; // default 1.0 // Scroll -@property (nonatomic, copy, nonnull) NSString* axis; // "vertical" | "horizontal" -@property (nonatomic) BOOL showIndicator; // default YES +@property(nonatomic, copy, nonnull) NSString *axis; // "vertical" | "horizontal" +@property(nonatomic) BOOL showIndicator; // default YES // Row vertical alignment — "top" | "center" (default) | "bottom" -@property (nonatomic, copy, nonnull) NSString* rowAlign; +@property(nonatomic, copy, nonnull) NSString *rowAlign; // Box content alignment — "top_leading" (default) | "center" | "top_center" | // "bottom_leading" | "bottom_center" | "bottom_trailing" | "top_trailing". // Affects how a box's children are placed within its frame; relevant when // the box has explicit width/height larger than the children. -@property (nonatomic, copy, nonnull) NSString* boxAlign; +@property(nonatomic, copy, nonnull) NSString *boxAlign; // Per-node offset applied as .offset(x:y:) on iOS / Modifier.offset on // Compose. Useful for absolute positioning within an aligned box. Default 0. -@property (nonatomic) CGFloat offsetX; -@property (nonatomic) CGFloat offsetY; +@property(nonatomic) CGFloat offsetX; +@property(nonatomic) CGFloat offsetY; // Spacer — fixedSize == 0 means fill available space -@property (nonatomic) CGFloat fixedSize; +@property(nonatomic) CGFloat fixedSize; // Progress — NaN means indeterminate -@property (nonatomic) CGFloat value; -@property (nonatomic, strong, nullable) UIColor* color; // track / indicator color +@property(nonatomic) CGFloat value; +@property(nonatomic, strong, nullable) UIColor *color; // track / indicator color // Layout behaviour -@property (nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) -@property (nonatomic) BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs -@property (nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) +@property(nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) +@property(nonatomic) + BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs +@property(nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) // Border (currently honored on box). Both must be set for a border to draw. -@property (nonatomic, strong, nullable) UIColor* borderColor; -@property (nonatomic) CGFloat borderWidth; // pt; default 0 = no border +@property(nonatomic, strong, nullable) UIColor *borderColor; +@property(nonatomic) CGFloat borderWidth; // pt; default 0 = no border // image -@property (nonatomic, copy, nullable) NSString* src; -@property (nonatomic, copy, nonnull) NSString* contentModeStr; // "fit" | "fill" | "stretch" -@property (nonatomic) CGFloat fixedWidth; // 0 = fill available -@property (nonatomic) CGFloat fixedHeight; // 0 = auto -@property (nonatomic, strong, nullable) UIColor* placeholderColor; +@property(nonatomic, copy, nullable) NSString *src; +@property(nonatomic, copy, nonnull) NSString *contentModeStr; // "fit" | "fill" | "stretch" +@property(nonatomic) CGFloat fixedWidth; // 0 = fill available +@property(nonatomic) CGFloat fixedHeight; // 0 = auto +@property(nonatomic, strong, nullable) UIColor *placeholderColor; // Typography -@property (nonatomic, copy, nullable) NSString* fontFamily; // nil = system font -@property (nonatomic, copy, nonnull) NSString* fontWeight; // "regular","medium","semibold","bold","light","thin" -@property (nonatomic, copy, nonnull) NSString* textAlign; // "left","center","right" -@property (nonatomic) BOOL italic; -@property (nonatomic) CGFloat lineHeight; // multiplier; 0 = default -@property (nonatomic) CGFloat letterSpacing; +@property(nonatomic, copy, nullable) NSString *fontFamily; // nil = system font +@property(nonatomic, copy, nonnull) + NSString *fontWeight; // "regular","medium","semibold","bold","light","thin" +@property(nonatomic, copy, nonnull) NSString *textAlign; // "left","center","right" +@property(nonatomic) BOOL italic; +@property(nonatomic) CGFloat lineHeight; // multiplier; 0 = default +@property(nonatomic) CGFloat letterSpacing; // Tab bar -@property (nonatomic, strong, nullable) NSArray* tabDefs; // array of NSDictionary, each with id/label/icon -@property (nonatomic, copy, nullable) NSString* activeTab; // selected tab id -@property (nonatomic, copy, nullable) void (^onTabSelect)(NSString*); // sends selected tab id as string +@property(nonatomic, strong, nullable) + NSArray *tabDefs; // array of NSDictionary, each with id/label/icon +@property(nonatomic, copy, nullable) NSString *activeTab; // selected tab id +@property(nonatomic, copy, nullable) void (^onTabSelect)(NSString *) + ; // sends selected tab id as string // Video player -@property (nonatomic) BOOL videoAutoplay; -@property (nonatomic) BOOL videoLoop; -@property (nonatomic) BOOL videoControls; +@property(nonatomic) BOOL videoAutoplay; +@property(nonatomic) BOOL videoLoop; +@property(nonatomic) BOOL videoControls; // Camera preview -@property (nonatomic, copy, nonnull) NSString* cameraFacing; // "back" | "front" +@property(nonatomic, copy, nonnull) NSString *cameraFacing; // "back" | "front" // WebView -@property (nonatomic, copy, nullable) NSString* webViewUrl; // URL to load -@property (nonatomic, copy, nullable) NSString* webViewAllow; // comma-separated allowed URL prefixes -@property (nonatomic) BOOL webViewShowUrl; -@property (nonatomic, copy, nullable) NSString* webViewTitle; // static title label; overrides show_url +@property(nonatomic, copy, nullable) NSString *webViewUrl; // URL to load +@property(nonatomic, copy, nullable) NSString *webViewAllow; // comma-separated allowed URL prefixes +@property(nonatomic) BOOL webViewShowUrl; +@property(nonatomic, copy, nullable) + NSString *webViewTitle; // static title label; overrides show_url // NativeView — rendered by MobNativeViewRegistry -@property (nonatomic, copy, nullable) NSString* nativeViewModule; // registry key (e.g. "MyApp_ChartComponent") -@property (nonatomic, copy, nullable) NSString* nativeViewId; // user-assigned id -@property (nonatomic) int nativeViewHandle; // NIF component handle for event callbacks -@property (nonatomic, strong, nullable) NSDictionary* nativeViewProps; // full props dict forwarded to the factory +@property(nonatomic, copy, nullable) + NSString *nativeViewModule; // registry key (e.g. "MyApp_ChartComponent") +@property(nonatomic, copy, nullable) NSString *nativeViewId; // user-assigned id +@property(nonatomic) int nativeViewHandle; // NIF component handle for event callbacks +@property(nonatomic, strong, nullable) + NSDictionary *nativeViewProps; // full props dict forwarded to the factory // Accessibility — set from the tap tag atom name; read by XCTest / ui_describe_all -@property (nonatomic, copy, nullable) NSString* accessibilityId; +@property(nonatomic, copy, nullable) NSString *accessibilityId; // Icon — logical name resolved to an SF Symbol on iOS / Material Symbol // on Android. textSize and textColor control glyph sizing + tint. -@property (nonatomic, copy, nullable) NSString* iconName; +@property(nonatomic, copy, nullable) NSString *iconName; // Canvas — declarative draw-op list from Mob.Canvas. Each entry is an // NSDictionary with an "op" key (e.g. "line", "circle") and op-specific // fields. Color values arrive pre-resolved (ARGB integers) from the // renderer's encode_canvas_op/2. -@property (nonatomic, strong, nullable) NSArray* canvasOps; -@property (nonatomic) CGFloat canvasWidth; // pt; required (>0) -@property (nonatomic) CGFloat canvasHeight; // pt; required (>0) +@property(nonatomic, strong, nullable) NSArray *canvasOps; +@property(nonatomic) CGFloat canvasWidth; // pt; required (>0) +@property(nonatomic) CGFloat canvasHeight; // pt; required (>0) // Children -@property (nonatomic, strong, nonnull) NSMutableArray* children; +@property(nonatomic, strong, nonnull) NSMutableArray *children; @end diff --git a/ios/mob_beam.h b/ios/mob_beam.h index a32ac3ff..f640b852 100644 --- a/ios/mob_beam.h +++ b/ios/mob_beam.h @@ -10,23 +10,23 @@ void mob_init_ui(void); // Call mob_start_beam on a background thread — erl_start never returns. // app_module: Erlang module name, e.g. "mob_demo" -void mob_start_beam(const char* app_module); +void mob_start_beam(const char *app_module); // Update the startup status shown on screen while BEAM is initialising. // mob_set_startup_error stalls the screen with an error message (does not crash). // Both are safe to call from any thread. -void mob_set_startup_phase(const char* phase); -void mob_set_startup_error(const char* error); +void mob_set_startup_phase(const char *phase); +void mob_set_startup_error(const char *error); // Call from AppDelegate didRegisterForRemoteNotificationsWithDeviceToken // to forward the APNs device token to the BEAM as {:push_token, :ios, hex_string}. // Convert the raw NSData to a hex string before calling. -void mob_send_push_token(const char* hex_token); +void mob_send_push_token(const char *hex_token); // Store a notification JSON payload that launched the app from a killed state. // Call from application:didFinishLaunchingWithOptions: or scene:willConnectTo: // when a remote/local notification is the launch cause. The BEAM will deliver // it via handle_info({:notification, ...}) after the root screen is mounted. -void mob_set_launch_notification_json(const char* json); +void mob_set_launch_notification_json(const char *json); #endif // MOB_BEAM_H diff --git a/ios/mob_nif.m b/ios/mob_nif.m index cf625aa3..c9a46c3d 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5635,59 +5635,72 @@ void mob_send_component_event(int handle, const char *event, const char *payload // event and degrade gracefully via Mob.Peripheral.capabilities/0. static void send_vendor_usb_unsupported(ErlNifPid pid) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple5(e, - enif_make_atom(e, "peripheral"), - enif_make_atom(e, "vendor_usb"), - enif_make_atom(e, "error"), - enif_make_atom(e, "nil"), - enif_make_atom(e, "unsupported")); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple5(e, enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), enif_make_atom(e, "unsupported")); enif_send(NULL, &pid, e, msg); enif_free_env(e); } -static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); send_vendor_usb_unsupported(pid); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); send_vendor_usb_unsupported(pid); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); send_vendor_usb_unsupported(pid); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); send_vendor_usb_unsupported(pid); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); send_vendor_usb_unsupported(pid); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; +static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - (void)argc; (void)argv; +static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; return enif_make_atom(env, "ok"); } @@ -5797,13 +5810,13 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv* env, int argc, const ERL_NIF {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, // ── Mob.Peripheral.VendorUsb (iOS stubs — emit :unsupported) ────────────── - {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, + {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, {"vendor_usb_request_permission", 1, nif_vendor_usb_request_permission, 0}, - {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, - {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, 0}, - {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, - {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, - {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, + {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, + {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, 0}, + {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, + {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, + {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, // getaddrinfo can block on the resolver for seconds — dirty-IO so it // doesn't head-of-line-block the regular schedulers. See the impl // above for the iOS rationale. From e7f362fbeb633e33cb9b8d09795160dc4ac379d0 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 00:42:33 -0600 Subject: [PATCH 058/254] 0.6.0 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index b4de7b49..82c20351 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.5.18", + version: "0.6.0", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 67471f8ed1f4b74a8719046dbca61c3eeb967a6b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 08:29:17 -0600 Subject: [PATCH 059/254] guides/native_extensions.md: summary of mob.enable + mob.add_nif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- guides/native_extensions.md | 115 ++++++++++++++++++++++++++++++++++++ mix.exs | 1 + 2 files changed, 116 insertions(+) create mode 100644 guides/native_extensions.md diff --git a/guides/native_extensions.md b/guides/native_extensions.md new file mode 100644 index 00000000..db2f6e69 --- /dev/null +++ b/guides/native_extensions.md @@ -0,0 +1,115 @@ +# Native Extensions + +Mob apps can be extended with native code in four ways, accessed +through two Mix tasks. This guide is a summary — the detailed +contract per backend (how Cargo / Zigler / Pythonx normally work, +what Mob changes for static linking, where the bundled Python +runtime comes from on each platform, which workarounds are +transient) lives in +[mob_dev's `guides/nifs.md`](https://hexdocs.pm/mob_dev/nifs.html). +Read that one before debugging a native build. + +## Two tasks, one decision + +| Question | Use | +|---|---| +| "I want to write a NIF I'll name myself." | `mix mob.add_nif ` | +| "I want to enable a pre-named Mob feature." | `mix mob.enable ` | + +The split tracks a real distinction. `add_nif` creates *instances* +the user names (`audio_engine`, `image_codec`, `crypto_utils`) and +can have many of. `enable` toggles *singleton features* with fixed +implementations (`pythonx`, `mlx`, `camera`, `notifications`) — each +exists at most once per app. + +## `mix mob.add_nif ` + +Scaffolds a statically-linked NIF: Elixir stub, native skeleton +appropriate to the chosen backend, `:static_nifs` entry in `mob.exs`, +and regenerated dispatch table — one command, one diff. + +```bash +mix mob.add_nif audio_engine # Elixir-only stub; you wire native side +mix mob.add_nif audio_engine --type c # also drops c_src/audio_engine.c +mix mob.add_nif audio_engine --type rustler # native/audio_engine/ Cargo crate + :rustler dep +mix mob.add_nif audio_engine --type zigler # ~Z sigil in the stub + :zigler dep +mix mob.add_nif audio_engine --type rustler --demo # also generates a demo screen +``` + +Why static linking? iOS App Store rejects bundled `.dylib`; Android +`RTLD_LOCAL` hides the parent's `enif_*` symbols from a `dlopen`'d +child. Both platforms force the same answer: link the NIF init +function into the main app binary alongside `libbeam.a`. mob_dev +handles the cross-compile and link automatically — you write the +Rust/Zig/C, run `mix mob.deploy --native`, and the right `.a` ends +up in the right place per arch. + +**Bringing in an existing Rust project** (one crate or many — there's +no upper limit) takes four manual steps documented in +[mob_dev's NIF guide](https://hexdocs.pm/mob_dev/nifs.html#bringing-in-an-existing-rust-crate). +You don't need to be a Rust expert to follow it — the steps are +copy-paste. + +## `mix mob.enable ` + +Toggles an optional feature with a fixed implementation. Patches +`mix.exs`, platform manifests (Info.plist / AndroidManifest.xml), +and any required source files in one Igniter run. + +| Feature | What it gives you | +|---|---| +| `liveview` | Phoenix LiveView mode — app renders a local web view | +| `camera` | Camera permission + capture API | +| `photo_library` | Photo picker + saving | +| `file_sharing` | iOS Files-app integration + Android FileProvider | +| `location` | Coarse + fine location permissions and API | +| `notifications` | Push notifications (entitlement + APNs / FCM glue) | +| `pythonx` | Embedded CPython interpreter on iOS + Android | +| `mlx` | Apple MLX tensor math + EMLX Nx backend (iOS) | + +```bash +mix mob.enable camera photo_library # multiple in one command +mix mob.enable pythonx # embeds CPython 3.13 on both platforms +mix mob.enable mlx # on-device tensor math (iOS, ~30 MB) +``` + +The `pythonx` and `mlx` features cost real bundle size (~70 MB and +~30 MB respectively). The rest are cheap (manifest entries + a few +hundred lines of generated Elixir/Swift/Kotlin). + +For exactly where Mob fetches the bundled CPython runtime from +(BeeWare's `Python-Apple-support` for iOS, Chaquopy for Android, why +two sources, what's identical between them) — see the Pythonx +section of [mob_dev's NIF guide](https://hexdocs.pm/mob_dev/nifs.html#python-via-pythonx). + +## What gets generated, where + +For any NIF added via `mob.add_nif ` (regardless of `--type`): + +``` +lib//nifs/.ex # Elixir stub module +mob.exs # :static_nifs entry appended +priv/generated/driver_tab_ios.zig # dispatch table (regenerated) +priv/generated/driver_tab_android.zig # dispatch table (regenerated) +``` + +Plus, depending on `--type`: + +``` +c_src/.c # --type c +native//Cargo.toml # --type rustler +native//src/lib.rs # --type rustler +native//.cargo/config.toml # --type rustler (macOS link flags) +``` + +For `mob.enable ` the file list varies per feature — see +the individual feature docs via `mix help mob.enable`. + +## Where to dig deeper + +| Topic | Location | +|---|---| +| Per-backend mechanics, how each upstream library works, what Mob changes, transient workarounds | [`mob_dev/guides/nifs.md`](https://hexdocs.pm/mob_dev/nifs.html) | +| Embedded CPython app integration (wheels, first-launch extraction, host-dev fallback) | [`mob_dev/guides/python_embedding.md`](https://hexdocs.pm/mob_dev/python_embedding.html) | +| `MobDev.StaticNifs` schema (arch values, per-arch symbol naming) | `MobDev.StaticNifs` module doc | +| Full task references | `mix help mob.add_nif`, `mix help mob.enable` | diff --git a/mix.exs b/mix.exs index 82c20351..b40c4811 100644 --- a/mix.exs +++ b/mix.exs @@ -75,6 +75,7 @@ defmodule Mob.MixProject do "guides/theming.md": [title: "Theming"], "guides/navigation.md": [title: "Navigation"], "guides/device_capabilities.md": [title: "Device Capabilities"], + "guides/native_extensions.md": [title: "Native Extensions (NIFs, features)"], "guides/dns_on_ios.md": [title: "DNS on iOS"], "guides/push_notifications.md": [title: "Push Notifications"], "guides/data.md": [title: "Data & Persistence"], From 2ba1cad7e2ebcd8598c914a06e918d6b090c6133 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 08:43:00 -0600 Subject: [PATCH 060/254] =?UTF-8?q?Mob.DNS.configure=5Fpure=5Fbeam/1=20?= =?UTF-8?q?=E2=80=94=20pure-BEAM=20DNS=20as=20default=20for=20iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- guides/dns_on_ios.md | 136 +++++++++++++++++++++++++++++----------- lib/mob/dns.ex | 140 ++++++++++++++++++++++++++++++++++++++---- test/mob/dns_test.exs | 69 +++++++++++++++++++++ 3 files changed, 297 insertions(+), 48 deletions(-) diff --git a/guides/dns_on_ios.md b/guides/dns_on_ios.md index 79272e70..f5cd58af 100644 --- a/guides/dns_on_ios.md +++ b/guides/dns_on_ios.md @@ -1,4 +1,4 @@ -# DNS on iOS — Why Req / Finch / Mint Fail Until You Call `Mob.DNS.resolve/1` +# DNS on iOS — Why Req / Finch / Mint Fail Without Configuring BEAM's DNS Path If you're running a mob app on iOS and you call out to an HTTPS endpoint by hostname — `Req.get!("https://api.example.com/...")` — the request @@ -6,27 +6,31 @@ fails. The same code works on macOS, Linux, the iOS simulator, Android, and physical Android. **Only the iOS device sees the failure**, and the error is usually some flavour of "nxdomain" or "lookup failed." -This document explains why that happens and how to make your app's HTTP -calls work on iOS with one extra line at startup. +This document explains why that happens and how to fix it. --- ## TL;DR ```elixir -# Once, before your first HTTP call (typically in your app's on_start/0): -Mob.DNS.preresolve([ - "api.example.com", - "auth.example.com" -]) +# Once, in your app's on_start/0 (already in the mob.new template): +Mob.DNS.configure_pure_beam() # Now Req / Finch / Mint / HTTPoison / Tesla all work normally: Req.get!("https://api.example.com/things") ``` -`Mob.DNS.resolve/1` is idempotent and cheap. You can also use the bulk -form `preresolve/1` for a fixed list of backends, or call `resolve/1` -lazily right before the first request to a given host. +`configure_pure_beam/1` flips BEAM's lookup chain from the broken +`:native` (port-program) path to `[:file, :dns]` — BEAM does raw DNS +queries from inside Erlang via `gen_udp` / `gen_tcp`, no fork, no +`execve`. Defaults to Google + Cloudflare as fallback nameservers; +override with `nameservers:` if you need to. + +For hosts that need iOS's own resolver — VPN-pushed DNS, `.local` / +mDNS, search-domain expansion, captive portals — use the per-host +`Mob.DNS.resolve/1` / `preresolve/1` path described below. Both +mechanisms compose; the per-host calls always win because `:file` is +first in the chain. --- @@ -64,29 +68,61 @@ does NOT affect" below. --- -## How `Mob.DNS` works around it +## Two ways to fix it + +`Mob.DNS` exposes two complementary mechanisms. They compose — call +`configure_pure_beam` once at startup as the default, then `resolve/1` +only for the specific hosts where Apple-resolver semantics matter. -iOS doesn't block calling libc functions in-process — only `execve`. -`Mob.DNS` calls Darwin's `getaddrinfo` directly via a NIF, then seeds -the result into `:inet_db` (BEAM's in-process host table) so subsequent -`:inet.getaddr/2` calls find it from the file table without ever -spawning anything. +### 1. `configure_pure_beam/1` — pure-BEAM DNS as default + +```elixir +def on_start do + Mob.DNS.configure_pure_beam() + # …rest of startup… +end +``` -The NIF does three things: +What it does: -1. Calls `getaddrinfo(host, NULL, &hints, &result)` with `hints.ai_family = AF_INET`. -2. Walks the result chain for the first IPv4 address. -3. Returns `{:ok, {a, b, c, d}}` or `{:error, reason}`. +1. Calls `:inet_db.set_lookup([:file, :dns])`. The `:dns` method + resolves via raw UDP/TCP queries performed inside BEAM by + `inet_res` (`gen_udp` / `gen_tcp`). No port program, no fork, no + `execve`. iOS doesn't block sockets, so this Just Works. +2. Seeds fallback nameservers — defaults to Google + Cloudflare + (`{8,8,8,8}` and `{1,1,1,1}`). Override via `nameservers:` opt. -The Elixir wrapper then: +After this, `:inet.getaddr/2` (and therefore the entire HTTP-library +ecosystem) resolves any public hostname without per-host setup. -1. Calls `:inet_db.add_host(ip, [host])` to seed the file table. -2. Calls `:inet_db.set_lookup([:file | other])` to put `:file` at the - front of BEAM's lookup chain (so seeded entries win over the broken - `:native` path). +### 2. `Mob.DNS.resolve/1` / `preresolve/1` — Apple-resolver-backed, per host -Both operations are idempotent. Calling `resolve/1` for the same host -twice is harmless. +```elixir +{:ok, _ip} = Mob.DNS.resolve("internal.corp.local") +``` + +What it does: + +1. Calls Darwin's `getaddrinfo` directly via a NIF (iOS allows + in-process libc calls — only `execve` of foreign binaries is + blocked). +2. Walks the result for the first IPv4 address. +3. Seeds it into `:inet_db`'s file table via `:inet_db.add_host/2`. +4. Ensures `:file` is first in the lookup chain so the seeded entry + wins over whatever comes after. + +Because the NIF goes through Apple's resolver, this path honours +**everything iOS knows about DNS** — VPN-pushed nameservers, search +domains, `.local` / mDNS, captive portals, encrypted-DNS configured +in iOS Settings. The pure-BEAM `:dns` path does none of that; it +just queries whatever nameservers you seeded. + +### How they compose + +`configure_pure_beam` puts `:file` first in the chain. So when you +later call `resolve/1` for `internal.corp.local`, the Apple-resolved +IP is added to the file table and **always wins** over the `:dns` +fallback. The two paths don't conflict. --- @@ -135,21 +171,49 @@ iOS has no comparable mechanism. The `Mob.DNS` NIF is the workaround. --- +## Trade-offs — pure-BEAM vs. Apple-resolver + +| Concern | `configure_pure_beam` (`:dns` method) | `resolve/1` / `preresolve/1` (libc NIF) | +|---|---|---| +| Who runs the DNS query? | BEAM's `inet_res` — raw UDP/TCP from Erlang | Apple's resolver, in-process via libc | +| Nameservers used | Whatever you seeded (defaults to Google + Cloudflare) | Whatever iOS knows about — DHCP, VPN, configured DoH/DoT | +| Captive portals (hotel / airport Wi-Fi) | Often broken — captive nets hijack DNS in ways the OS handles, raw UDP doesn't | Handled by iOS | +| Corporate / VPN DNS for internal hostnames | Doesn't work unless you also seed the corporate resolver | Works — iOS picks up DNS pushed by the VPN profile | +| Search-domain expansion (single-label `https://api/`) | Not applied | Applied by iOS resolver | +| `.local` / mDNS service discovery | Doesn't work | Works | +| IPv6 dual-stack (Happy Eyeballs) | Manual | Automatic | +| TTL respected; auto-refresh when an IP rotates | Yes (DNS TTLs honoured per lookup) | No — `inet_db` seed persists until you re-`resolve/1` | +| Cost per lookup | UDP round-trip every time `:dns` fires | Zero after the first call (cached in `inet_db`) | +| Per-host setup required? | No | Yes (`resolve/1` per hostname, or `preresolve/1` for a batch) | +| Code surface | One function call at startup | NIF + Elixir wrapper | + +**Default to `configure_pure_beam`.** It covers everything most apps +talk to (consumer Wi-Fi or cellular + public-internet endpoints) with +one line of setup. + +**Reach for `resolve/1` per-host** when you specifically need the +Apple-resolver behaviour from the table above. The two compose — the +manually-seeded entry always wins over the `:dns` fallback because +`:file` is first in the chain. + +--- + ## When to call `resolve` / `preresolve` -**At app startup, for known-fixed backends.** This is the simplest -pattern — list every backend your app talks to and resolve them once -in `on_start/0`: +**At app startup, for known-fixed backends.** List the backends that +need Apple-resolver semantics (VPN, mDNS, etc.) and resolve them +alongside the `configure_pure_beam` call: ```elixir def on_start do - Mob.Dist.ensure_started(...) + Mob.DNS.configure_pure_beam() # public-internet hosts - Mob.DNS.preresolve([ - "api.example.com", - "auth.example.com", - "analytics.example.com" + Mob.DNS.preresolve([ # OS-resolver-special hosts + "internal.corp.local", + "files.local" ]) + + # …rest of startup… end ``` diff --git a/lib/mob/dns.ex b/lib/mob/dns.ex index 04624ecd..d67d28fb 100644 --- a/lib/mob/dns.ex +++ b/lib/mob/dns.ex @@ -27,25 +27,45 @@ defmodule Mob.DNS do ## How to use it - Resolve each hostname your app talks to **before** the first - Req / Finch / Mint call to that host. Once resolved, `:inet_db` - retains the mapping for the lifetime of the BEAM, so subsequent - HTTP calls go through without you doing anything else. + ### Recommended (set-and-forget): `configure_pure_beam/1` - # At app startup, or before the first call: - {:ok, _ip} = Mob.DNS.resolve("api.example.com") + At app startup, flip BEAM's lookup chain from the broken `:native` + path to `[:file, :dns]` and seed fallback nameservers. After this, + every `:inet.getaddr/2` resolves via raw DNS queries from inside + BEAM (no port program, no `execve`), and the usual HTTP libraries + just work: - # Now this just works on iOS: - Req.get!("https://api.example.com/v1/things") + def on_start do + Mob.DNS.configure_pure_beam() + # …rest of startup… + end - For a small fixed set of hosts, the convenience helper - `preresolve/1` does the whole list at once: + Defaults to Google + Cloudflare DNS. Override via opt if your + network requires it. See `configure_pure_beam/1` for details and + trade-offs vs the Apple-resolver-via-NIF path below. + + ### Per-host (when Apple-resolver semantics matter): `resolve/1` + + For hostnames that need iOS's resolver — VPN-pushed DNS, `.local` + / mDNS, search-domain expansion, captive portals — call + `resolve/1` for each one. Idempotent and cheap; safe to call + alongside `configure_pure_beam/0` (the `:file` lookup runs first, + so manually-resolved entries win over the `:dns` fallback). + + {:ok, _ip} = Mob.DNS.resolve("internal.corp.local") + + For a small fixed set, `preresolve/1` does the whole list at + once: Mob.DNS.preresolve([ - "api.example.com", - "auth.example.com" + "internal.corp.local", + "service.local" ]) + Both paths compose. The recommended pattern is `configure_pure_beam` + at startup as the default, then `resolve/1` only for the OS-resolver + specials. + ## Scope and limitations - **IPv4 only.** Most cloud endpoints serve A records; IPv6 is a @@ -130,6 +150,79 @@ defmodule Mob.DNS do Map.new(hosts, fn host -> {host, resolve(host)} end) end + @doc """ + Configure BEAM's DNS path so `:inet.getaddr/2` (and Req / Finch / + Mint / `gen_tcp:connect/3` with a hostname) works without per-host + setup. + + Sets the lookup chain to `[:file, :dns]` and seeds fallback + nameservers. Both ops are idempotent. + + ## Why + + BEAM's default `:native` lookup spawns `inet_gethost`, which iOS + refuses to `execve`. The `:dns` lookup, by contrast, performs raw + UDP/TCP DNS queries from inside BEAM via `gen_udp` / `gen_tcp` — + no port program, no fork. iOS doesn't block sockets, so the `:dns` + path Just Works. + + After calling this, the whole `:inet`-mediated HTTP stack stops + needing a per-host `resolve/1` call. `:file` stays first in the + chain so any host you do `resolve/1` manually still wins — the two + paths compose. + + ## When NOT to default to this + + Reach for per-host `resolve/1` (which uses libc `getaddrinfo` via + the NIF, going through Apple's resolver) when you need any of: + + * VPN-pushed DNS for internal hostnames + * `.local` / mDNS service discovery + * Search-domain expansion (single-label hostnames like `https://api/`) + * Captive-portal-aware lookup + * Encrypted-DNS-at-OS-level (DoH / DoT configured in iOS Settings) + + These all require Apple's resolver, which only the NIF path + consults. The pure-BEAM `:dns` path queries whatever nameservers + you seed and nothing else. + + ## Opts + + * `:nameservers` — list of nameserver IP tuples (IPv4 or IPv6). + Defaults to `[{8, 8, 8, 8}, {1, 1, 1, 1}]` (Google + Cloudflare). + Pass any list, including `[]` to skip seeding (e.g. if your + app's `:kernel` env already configures them). Common + alternatives: + + * `[{9, 9, 9, 9}]` — Quad9 (privacy-leaning, no logging) + * `[{10, 0, 0, 1}, {10, 0, 0, 2}]` — your corporate resolvers + + ## Idempotent + + Calling this twice is a no-op on the second call — duplicate + nameservers aren't added, the lookup chain isn't reordered. + + ## Examples + + # Default — most apps need nothing more + Mob.DNS.configure_pure_beam() + + # Override the fallback nameservers + Mob.DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}]) + + # Set the lookup chain but skip nameserver seeding + Mob.DNS.configure_pure_beam(nameservers: []) + """ + @spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok + def configure_pure_beam(opts \\ []) do + nameservers = Keyword.get(opts, :nameservers, [{8, 8, 8, 8}, {1, 1, 1, 1}]) + + set_lookup_chain([:file, :dns]) + Enum.each(nameservers, &add_ns_if_missing/1) + + :ok + end + @doc """ True when `host` is already seeded in `:inet_db`. @@ -178,4 +271,27 @@ defmodule Mob.DNS do :ok end end + + # Set the lookup chain to exactly `chain` if it isn't already. + # Used by `configure_pure_beam/1` to flip to `[:file, :dns]`. + defp set_lookup_chain(chain) do + if :inet_db.res_option(:lookup) != chain do + :inet_db.set_lookup(chain) + end + + :ok + end + + # Add a nameserver to `:inet_db` if not already configured. + # `:inet_db.res_option(:nameservers)` returns `[{ip, port}]`; + # `add_ns/1` adds at default port 53. + defp add_ns_if_missing(ns) do + existing = :inet_db.res_option(:nameservers) + + unless Enum.any?(existing, fn {ip, _port} -> ip == ns end) do + :inet_db.add_ns(ns) + end + + :ok + end end diff --git a/test/mob/dns_test.exs b/test/mob/dns_test.exs index 2ee92232..91504bd3 100644 --- a/test/mob/dns_test.exs +++ b/test/mob/dns_test.exs @@ -8,10 +8,23 @@ defmodule Mob.DNSTest do setup do original_lookup = :inet_db.res_option(:lookup) + original_ns = :inet_db.res_option(:nameservers) on_exit(fn -> # Restore the lookup order so other tests aren't affected. :inet_db.set_lookup(original_lookup) + + # Restore nameservers — `configure_pure_beam/1` adds {8.8.8.8, 53} + # and {1.1.1.1, 53} by default, which would leak across tests. + # `set_resolv_conf("")` clears all then `add_ns/1` per restored entry. + for {ip, port} <- :inet_db.res_option(:nameservers) do + :inet_db.del_ns(ip, port) + end + + for {ip, port} <- original_ns do + :inet_db.add_ns(ip, port) + end + # Best-effort host-table cleanup for the names we used. for host <- ~c"a.test a.test.local b.test missing.test bogus.test" @@ -140,4 +153,60 @@ defmodule Mob.DNSTest do assert DNS.resolved?("chain.test") end end + + # ── configure_pure_beam/1 ────────────────────────────────────────────── + # + # Flips BEAM's lookup chain to `[:file, :dns]` and seeds nameservers so + # `:inet.getaddr/2` resolves via raw DNS queries from inside BEAM + # instead of the iOS-broken `:native` (inet_gethost) path. Pure state + # mutation on `:inet_db`; nothing to mock. + + describe "configure_pure_beam/1" do + test "sets the lookup chain to [:file, :dns]" do + DNS.configure_pure_beam(nameservers: []) + assert :inet_db.res_option(:lookup) == [:file, :dns] + end + + test "seeds Google + Cloudflare DNS by default" do + DNS.configure_pure_beam() + nameservers = :inet_db.res_option(:nameservers) + ips = Enum.map(nameservers, fn {ip, _port} -> ip end) + assert {8, 8, 8, 8} in ips + assert {1, 1, 1, 1} in ips + end + + test "honors a custom :nameservers list" do + DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}]) + ips = :inet_db.res_option(:nameservers) |> Enum.map(fn {ip, _port} -> ip end) + assert {9, 9, 9, 9} in ips + refute {8, 8, 8, 8} in ips + end + + test ":nameservers: [] sets the lookup chain but skips ns seeding" do + # Snapshot ns count before so we don't false-positive on a leftover + # from another test (setup restores, but order isn't guaranteed). + before = length(:inet_db.res_option(:nameservers)) + DNS.configure_pure_beam(nameservers: []) + assert :inet_db.res_option(:lookup) == [:file, :dns] + assert length(:inet_db.res_option(:nameservers)) == before + end + + test "is idempotent — calling twice doesn't duplicate nameservers" do + DNS.configure_pure_beam(nameservers: [{8, 8, 8, 8}]) + first = length(:inet_db.res_option(:nameservers)) + DNS.configure_pure_beam(nameservers: [{8, 8, 8, 8}]) + second = length(:inet_db.res_option(:nameservers)) + assert first == second + end + + test "preserves manually-seeded :file entries (composes with resolve/1)" do + # The whole point of `:file` being first in the chain — manually- + # resolved hosts (Apple-resolver-backed) still win over the :dns + # fallback, so a user can use configure_pure_beam as a default and + # selectively call resolve/1 for VPN/mDNS hosts. + :inet_db.add_host({203, 0, 113, 50}, [~c"compose.test"]) + DNS.configure_pure_beam(nameservers: []) + assert DNS.resolved?("compose.test") + end + end end From b48d9935a14ea061e685cc5f1c877bcf0f72bdce Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 08:47:01 -0600 Subject: [PATCH 061/254] 0.6.1 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index b40c4811..24939d65 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.0", + version: "0.6.1", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 79bd96a994952452704a5abbf81e96db697069ec Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 16:04:28 -0600 Subject: [PATCH 062/254] docs(permissions): new guide + per-module cross-refs; iOS location honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- guides/device_capabilities.md | 10 ++ guides/permissions.md | 224 ++++++++++++++++++++++++++++++++++ ios/mob_nif.m | 95 +++++++++++++- lib/mob/audio.ex | 6 + lib/mob/camera.ex | 8 +- lib/mob/location.ex | 16 +++ lib/mob/notify.ex | 5 + lib/mob/permissions.ex | 7 ++ lib/mob/photos.ex | 9 +- mix.exs | 1 + 10 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 guides/permissions.md diff --git a/guides/device_capabilities.md b/guides/device_capabilities.md index dee72e73..2306a217 100644 --- a/guides/device_capabilities.md +++ b/guides/device_capabilities.md @@ -25,6 +25,16 @@ end **No permission needed:** haptics, clipboard, share sheet, file picker. +> **`Mob.Permissions.request/2` is only half the picture.** Each +> permission-gated capability also needs an `Info.plist` usage +> description (iOS) and `AndroidManifest.xml` `uses-permission` entry +> (Android). The default `mix mob.new` template covers camera + +> microphone on iOS and most capabilities on Android, but leaves +> location, photo library, etc. for you to add explicitly. See +> [permissions](permissions.html) for the per-capability table, the +> iOS-specific gotchas, and a diagnostic checklist for "the dialog +> never appears". + ## Haptic feedback `Mob.Haptic.trigger/2` fires synchronously (no `handle_info` needed) and returns the socket: diff --git a/guides/permissions.md b/guides/permissions.md new file mode 100644 index 00000000..18cd614e --- /dev/null +++ b/guides/permissions.md @@ -0,0 +1,224 @@ +# Permissions + +Single source of truth for the OS-level permissions Mob exposes, the +manifest / `Info.plist` entries each one requires, and the +platform-specific gotchas that aren't covered by the runtime API alone. + +If you're hitting "the dialog never appears" or "I called the NIF and +nothing happened", this is the first place to look. + +## TL;DR + +* Call `Mob.Permissions.request(socket, :capability)` from your screen. +* The result arrives as `handle_info({:permission, :capability, :granted | :denied}, socket)`. +* iOS additionally needs the matching `NS*UsageDescription` key in `ios/Info.plist`. Without it, the dialog is silently suppressed and you get nothing — no event, no error. +* Android additionally needs the matching `uses-permission` line in `AndroidManifest.xml`. The `mob.new` template ships most of these already; if you added a feature after generating the project, double-check. + +## The per-capability table + +| `Mob.Permissions` cap | iOS `Info.plist` key | Android `uses-permission` | Notes | +|-------------------------|-----------------------------------------------------------------|-------------------------------------------------------------------------------------------|-------| +| `:camera` | `NSCameraUsageDescription` | `android.permission.CAMERA` | Required by `Mob.Camera`. `CameraPreview` *also* needs the plist key but does not call `Mob.Permissions.request/2` — request explicitly before mounting it. | +| `:microphone` | `NSMicrophoneUsageDescription` | `android.permission.RECORD_AUDIO` | Required by `Mob.Audio.start_recording/2` and by `Mob.Camera.capture_video/2`. | +| `:photo_library` | `NSPhotoLibraryUsageDescription` | API 33+: `READ_MEDIA_IMAGES` + `READ_MEDIA_VIDEO`. API ≤32: `READ_EXTERNAL_STORAGE`. | Required by `Mob.Photos.pick/2`. | +| `:location` | `NSLocationWhenInUseUsageDescription` | `ACCESS_FINE_LOCATION` (high accuracy) and/or `ACCESS_COARSE_LOCATION` (low accuracy). | See [iOS notes below](#ios-location-extras) — the dialog timing is unusual. | +| `:notifications` | (none — handled by `UNUserNotificationCenter`) | API 33+: `android.permission.POST_NOTIFICATIONS` | iOS shows the dialog the first time `request/2` runs. Android API ≤32 doesn't need a permission at all (notifications are user-controllable in Settings). | + +Capabilities that need **no runtime permission** on either platform and +do not appear in the table: + +* `Mob.Haptic`, `Mob.Clipboard`, `Mob.Share`, `Mob.Files.pick/2`, + `Mob.Toast`, `Mob.Alert`, `Mob.WebView`, `Mob.Motion`, `Mob.Biometric` + (uses biometric prompt UI but does not require a permission grant), + `Mob.Storage` (app-local paths only). + +Capabilities that need an `Info.plist` or manifest entry **without** going +through `Mob.Permissions.request/2`: + +| Operation | iOS `Info.plist` key | Android | +|-------------------------------------------------------------------|---------------------------------|---------| +| `Mob.Storage.save_to_photo_library/2` | `NSPhotoLibraryAddUsageDescription` | Same `READ_MEDIA_*` family as `:photo_library` on API 33+. | +| `Mob.Audio.play/2` (no permission) | none | none | +| `Mob.Camera.start_preview/2` (no permission for the *preview*; capture still needs `:camera`) | `NSCameraUsageDescription` | `CAMERA` | + +## What the `mob.new` template ships by default + +If you generate a fresh project with `mix mob.new`, the template emits: + +* **`ios/Info.plist`** — `NSCameraUsageDescription` and `NSMicrophoneUsageDescription`. Nothing else. +* **`android/app/src/main/AndroidManifest.xml`** — `CAMERA`, `RECORD_AUDIO`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, `READ_EXTERNAL_STORAGE` (API ≤32 only), `POST_NOTIFICATIONS`, `VIBRATE`, `FOREGROUND_SERVICE`, `INTERNET`, `RECEIVE_BOOT_COMPLETED`. + +So out-of-the-box your project covers camera + microphone on both +platforms, plus everything Android needs for the other capabilities. +**Anything iOS-side beyond camera + mic needs you to add the +`Info.plist` key yourself** before the first time you call that +capability. The most common ones to add: + +```xml +NSLocationWhenInUseUsageDescription +MyApp shows your location to ... + +NSPhotoLibraryUsageDescription +MyApp lets you pick photos from your library. + +NSPhotoLibraryAddUsageDescription +MyApp saves captures to your photo library. +``` + +If you ship without the key, iOS won't even log the missing-key error +in any obvious place — the dialog just silently doesn't appear, and +the underlying `request*Authorization` call no-ops. Symptom looks +identical to "the user denied permission" except no `denied` event +ever arrives. + +## iOS-specific notes + +### iOS location extras + +Apple's `CLLocationManager` couples permission and updates more +tightly than the other capabilities. Mob exposes both paths: + +1. `Mob.Permissions.request(socket, :location)` calls + `requestWhenInUseAuthorization` on a dedicated `CLLocationManager` + and reports the user's actual choice as `{:permission, :location, + :granted | :denied}` once the dialog is dismissed (or immediately + if the permission was previously decided). + +2. `Mob.Location.get_once/1` and `Mob.Location.start/2` *also* + trigger the dialog if `request/2` wasn't called yet. The dialog + is one-shot per app install — subsequent calls short-circuit + with the cached authorization. + +3. If the user denies, two events flow: + - `Mob.Permissions.request/2`'s caller hears `{:permission, + :location, :denied}`. + - `Mob.Location.get_once/1`/`start/2`'s caller hears + `{:location, :error, :permission_denied}` (via the + `locationManagerDidChangeAuthorization:` callback). This means + a screen that skipped `request/2` and went straight to + `get_once` still has a way to break out of the "waiting for + fix…" state. + +4. The `Allow Once` button on iOS counts as `:granted` for the + current run of the app. The next launch will prompt again. + +5. Authorization can change mid-session — the user pops out to + Settings and revokes. The delegate fires + `{:location, :error, :permission_denied}` when that happens; + surface it in your screen if you care about long-running tracking + sessions. + +### Camera + microphone + +These go through `AVFoundation`'s `requestAccessForMediaType`, which +fires the dialog at `request/2` time. No additional gotchas — make +sure the plist key is present, the dialog appears, you get a typed +`{:permission, :camera | :microphone, ...}` event. + +### Photo library + +`PHPhotoLibrary.requestAuthorizationForAccessLevel:PHAccessLevelReadWrite` +is what `:photo_library` invokes. iOS treats +`PHAuthorizationStatusLimited` (the user picked "Selected Photos…") +as `:granted` from your screen's perspective — the rest of `Mob.Photos` +deals with the limited-access set transparently. + +### Notifications + +Uses `UNUserNotificationCenter requestAuthorizationWithOptions:`. Asks +for alert, sound, and badge in one shot. The current implementation +returns `:granted` if the user granted any of the three. + +## Android-specific notes + +### Foreground vs background location + +Mob only requests *foreground* location (`ACCESS_FINE_LOCATION` / +`ACCESS_COARSE_LOCATION`). If your app needs to keep tracking while +backgrounded, you need to additionally declare +`ACCESS_BACKGROUND_LOCATION` in the manifest and request it through +a custom flow — `Mob.Permissions.request/2` doesn't surface that +capability today. + +### Notifications on Android ≤ 12 + +Pre-API-33, posting a notification does not require a runtime +permission grant — the user controls it via Settings. The +`{:permission, :notifications, :granted}` event will still fire from +`request/2` so your screen code stays portable. + +### Storage and photos + +API 33+ replaced the single `READ_EXTERNAL_STORAGE` permission with +per-media-type permissions (`READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`). +The `mob.new` template declares all of them so the photo picker works +across API levels. Saving with `Mob.Storage.save_to_photo_library/2` +uses `MediaStore`, which doesn't require a permission on API 29+ at +all — the manifest declarations are only for the read path. + +## Re-requesting after denial + +Calling `Mob.Permissions.request/2` again *after* the user denied +does **not** re-show the dialog on either platform — that's an OS +restriction. The event still arrives (with `:denied`), so your screen +can re-render an explanation. To actually re-prompt the user, they +have to go through system Settings: + +* iOS: Settings → MyApp → \ +* Android: Settings → Apps → MyApp → Permissions → \ + +A common UX is: on `:denied`, show a "Permission needed — open +Settings" CTA. `Mob.OpenUrl.open/2` with the appropriate scheme +(`"app-settings:"` on iOS, `Intent.ACTION_APPLICATION_DETAILS_SETTINGS` +on Android — surfaced via `Mob.System.open_app_settings/1` if your +project has it; otherwise call the manifest-permitted scheme directly) +will jump straight to the right settings page. + +## Diagnosing a stuck request + +Symptom: you called `Mob.Permissions.request/2` (or a capability +function), no dialog appears, no `:permission`/`:error` event ever +arrives. + +Run through this in order: + +1. **iOS plist key present?** Open `ios/Info.plist` (or the rendered + bundle inside the `.app`) and confirm the `NS*UsageDescription` + for the capability is there. The single most common cause. +2. **Android manifest entry present?** Open + `android/app/src/main/AndroidManifest.xml`. If you added the + feature post-`mob.new`, the entry may be missing. +3. **Already denied at the OS level?** iOS: Settings → + MyApp → \. Android: Settings → Apps → MyApp → + Permissions. A previously-denied permission won't re-prompt; + `request/2` still fires the `:denied` event, so check your + `handle_info({:permission, :cap, :denied}, _)` clause exists. +4. **The screen process actually still alive?** If your screen + crashed before `handle_info/2` ran, the message is lost. Check + `adb logcat` or the iOS device console for a crash earlier in the + pipeline. +5. **You're calling `request/2` from a non-screen process.** + `enif_send` targets the calling pid; if a Task or `spawn` ran the + request, its inbox is where the event went. Always request from + the screen GenServer. + +## Cross-platform pattern + +```elixir +def mount(_params, _session, socket) do + # Cheap and idempotent on both platforms. Safe to call even if + # you're not yet ready to use the capability — the response + # informs whether the action button below should be enabled. + socket = Mob.Permissions.request(socket, :location) + {:ok, Mob.Socket.assign(socket, permission: :pending)} +end + +def handle_info({:permission, :location, :granted}, socket) do + {:noreply, Mob.Socket.assign(socket, permission: :granted)} +end + +def handle_info({:permission, :location, :denied}, socket) do + # Render a "needs permission — open Settings" CTA. + {:noreply, Mob.Socket.assign(socket, permission: :denied)} +end +``` diff --git a/ios/mob_nif.m b/ios/mob_nif.m index c9a46c3d..4e9ddc87 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2089,9 +2089,14 @@ static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_N ok ? "granted" : "denied"); }]; } else if (strcmp(cap, "location") == 0) { - // Location permission is requested via CLLocationManager when get_once/start are called. - // Here we just signal granted for iOS (the actual dialog shows at location call time). - mob_send3(&pid, "permission", "location", "granted"); + // Honest location-permission flow: drive CLLocationManager + // directly and let its delegate report the user's actual + // choice. See request_location_permission/1 below for the + // delegate setup. Previously this branch synthesised + // `:granted` unconditionally — that lied about denials, hid + // the "not determined → no plist key" failure mode, and made + // Mob.Permissions behave differently on iOS vs Android. + request_location_permission(pid); } else if (strcmp(cap, "notifications") == 0) { UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; [center @@ -2137,6 +2142,67 @@ static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv *env, int argc, return enif_make_atom(env, "ok"); } +// ── Location permission ─────────────────────────────────────────────────── +// +// Separated from the location-updates delegate below so a screen can +// request authorization without also starting (and paying for) GPS +// updates. The delegate fires once per real-user choice; we map +// AuthorizedWhenInUse / AuthorizedAlways → :granted, Denied / +// Restricted → :denied. NotDetermined is the transient state before +// the dialog has been answered — we keep the delegate alive (static +// strong refs) so iOS can call back into it when the answer arrives. + +@interface MobLocationPermissionDelegate : NSObject +@property(nonatomic) ErlNifPid pid; +@property(nonatomic) BOOL resolved; +@end + +static MobLocationPermissionDelegate *g_permission_delegate = nil; +static CLLocationManager *g_permission_manager = nil; + +@implementation MobLocationPermissionDelegate +// `locationManagerDidChangeAuthorization:` is the iOS 14+ replacement +// for `locationManager:didChangeAuthorizationStatus:`. Mob targets +// iOS 17+ (see ios/build_device.zig minimum-deployment) so the older +// callback is omitted. +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { + CLAuthorizationStatus status = manager.authorizationStatus; + if (status == kCLAuthorizationStatusNotDetermined) { + // Dialog is still on screen / the OS hasn't picked an initial + // state. We'll be called again with the user's choice. + return; + } + if (self.resolved) { + // Subsequent authorization changes (user revokes/grants via + // Settings) — fire the event again so the screen can react. + // Marked here for symmetry, no early return. + } + self.resolved = YES; + + ErlNifPid p = self.pid; + BOOL granted = (status == kCLAuthorizationStatusAuthorizedWhenInUse || + status == kCLAuthorizationStatusAuthorizedAlways); + mob_send3(&p, "permission", "location", granted ? "granted" : "denied"); +} +@end + +static void request_location_permission(ErlNifPid pid) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_permission_manager) { + g_permission_manager = [[CLLocationManager alloc] init]; + } + g_permission_delegate = [[MobLocationPermissionDelegate alloc] init]; + g_permission_delegate.pid = pid; + g_permission_manager.delegate = g_permission_delegate; + // Reading `authorizationStatus` here would tell us if we should + // skip the request entirely, but doing so synchronously can + // momentarily return NotDetermined on first launch. Calling + // `requestWhenInUseAuthorization` is idempotent — already-granted + // permissions short-circuit and the delegate fires immediately. + [g_permission_manager requestWhenInUseAuthorization]; + }); +} + // ── Location ────────────────────────────────────────────────────────────── @interface MobLocationDelegate : NSObject @@ -2181,6 +2247,29 @@ - (void)locationManager:(CLLocationManager *)mgr didFailWithError:(NSError *)err enif_send(NULL, &p, e, msg); enif_free_env(e); } +// Surface authorization-state changes through the same delegate so a +// screen that called `Mob.Location.get_once/1` without first going +// through `Mob.Permissions.request/2` still hears about denial — +// without this, `didFailWithError` doesn't fire on denial and the +// screen sits at "waiting for fix…" forever. The permission-only +// delegate above sends `{:permission, :location, ...}`; here we send +// `{:location, :error, :permission_denied}` so the screen's +// `handle_info({:location, :error, _}, _)` path catches it. +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)mgr { + CLAuthorizationStatus status = mgr.authorizationStatus; + if (status == kCLAuthorizationStatusNotDetermined) return; + if (status == kCLAuthorizationStatusAuthorizedWhenInUse || + status == kCLAuthorizationStatusAuthorizedAlways) { + return; + } + ErlNifPid p = self.pid; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "location"), enif_make_atom(e, "error"), + enif_make_atom(e, "permission_denied")); + enif_send(NULL, &p, e, msg); + enif_free_env(e); +} @end static void setup_location_manager(ErlNifPid pid, BOOL oneShot, NSString *accuracy) { diff --git a/lib/mob/audio.ex b/lib/mob/audio.ex index 2b180ce3..bcdc1637 100644 --- a/lib/mob/audio.ex +++ b/lib/mob/audio.ex @@ -3,6 +3,12 @@ defmodule Mob.Audio do Microphone recording and audio playback. Recording requires `:microphone` permission (`Mob.Permissions.request/2`). + iOS additionally needs `NSMicrophoneUsageDescription` in + `Info.plist`; Android needs `RECORD_AUDIO` in + `AndroidManifest.xml`. The default `mix mob.new` templates ship + both. See the [permissions guide](permissions.html) for the + cross-platform table. + Playback requires no permission. ## Recording diff --git a/lib/mob/camera.ex b/lib/mob/camera.ex index 3d3ad4d2..706aaffb 100644 --- a/lib/mob/camera.ex +++ b/lib/mob/camera.ex @@ -2,7 +2,13 @@ defmodule Mob.Camera do @moduledoc """ Native camera capture for photos and videos. - Requires `:camera` permission (and `:microphone` for video). + Requires `:camera` permission (and `:microphone` for video). iOS + additionally needs `NSCameraUsageDescription` (and + `NSMicrophoneUsageDescription` for video) in `Info.plist`; + Android needs `CAMERA` (and `RECORD_AUDIO` for video) in + `AndroidManifest.xml`. The default `mix mob.new` templates ship + both. See the [permissions guide](permissions.html) for the + cross-platform table. Opens the native OS camera UI. Results arrive as: diff --git a/lib/mob/location.ex b/lib/mob/location.ex index b8f3c347..c7f2201e 100644 --- a/lib/mob/location.ex +++ b/lib/mob/location.ex @@ -3,12 +3,28 @@ defmodule Mob.Location do Device location (GPS / network). Requires `:location` permission (request via `Mob.Permissions.request/2`). + iOS additionally needs `NSLocationWhenInUseUsageDescription` in + `Info.plist`; Android needs `ACCESS_FINE_LOCATION` and/or + `ACCESS_COARSE_LOCATION` in `AndroidManifest.xml`. See the + [permissions guide](permissions.html) for the cross-platform table + and the "the dialog never appears" failure mode — a missing plist + key or manifest entry is the single most common reason this module + silently does nothing. Location updates arrive as: handle_info({:location, %{lat: lat, lon: lon, accuracy: acc, altitude: alt}}, socket) handle_info({:location, :error, reason}, socket) + Common `reason` atoms: + + * `:permission_denied` — user denied `:location` (or revoked it + mid-session via Settings). iOS surfaces this through + `locationManagerDidChangeAuthorization:`; Android via the + permission flow. + * `:unavailable` — the OS can't get a fix right now + (`CLLocationManager.didFailWithError`). + iOS: `CLLocationManager`. Android: `FusedLocationProviderClient`. """ diff --git a/lib/mob/notify.ex b/lib/mob/notify.ex index 9b8c3d39..2dbb3bcb 100644 --- a/lib/mob/notify.ex +++ b/lib/mob/notify.ex @@ -3,6 +3,11 @@ defmodule Mob.Notify do Local and push notifications. Requires `:notifications` permission (request via `Mob.Permissions.request/2`). + No `Info.plist` key needed on iOS. Android 13+ (API 33) requires + `POST_NOTIFICATIONS` in `AndroidManifest.xml`; older Android + versions are user-controlled via system settings. The default + `mix mob.new` template ships `POST_NOTIFICATIONS`. See the + [permissions guide](permissions.html) for the cross-platform table. All notifications arrive via `handle_info` regardless of app state (foreground, background, or relaunched after being killed). No special `mount/3` handling needed. diff --git a/lib/mob/permissions.ex b/lib/mob/permissions.ex index d1fe24cd..b3d74336 100644 --- a/lib/mob/permissions.ex +++ b/lib/mob/permissions.ex @@ -14,6 +14,13 @@ defmodule Mob.Permissions do - `:notifications` Capabilities that need *no* permission: haptics, clipboard, share sheet, file picker. + + > **Beyond `request/2`**: each capability also needs a matching + > `Info.plist` key (iOS) and `AndroidManifest.xml` entry. Without + > them the dialog is silently suppressed and you get no event. See + > the [permissions guide](permissions.html) for the per-capability + > table and the most common failure modes — it's the first place + > to check when "the dialog never appears". """ @type capability :: :camera | :microphone | :photo_library | :location | :notifications diff --git a/lib/mob/photos.ex b/lib/mob/photos.ex index eb869e93..02e39792 100644 --- a/lib/mob/photos.ex +++ b/lib/mob/photos.ex @@ -2,8 +2,13 @@ defmodule Mob.Photos do @moduledoc """ Photo / video library picker. - On iOS 14+ no permission is required (the picker itself is sandboxed). - On Android, `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` may be needed. + On iOS 14+ no permission is required for the picker (it runs out of + process). `Mob.Storage.save_to_photo_library/2` does require + `NSPhotoLibraryAddUsageDescription` in `Info.plist`. On Android, + `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` (API 33+) or + `READ_EXTERNAL_STORAGE` (API ≤ 32) need to be declared in + `AndroidManifest.xml` — `mix mob.new` ships all three. See the + [permissions guide](permissions.html) for the cross-platform table. Results arrive as: diff --git a/mix.exs b/mix.exs index 24939d65..862c6a84 100644 --- a/mix.exs +++ b/mix.exs @@ -75,6 +75,7 @@ defmodule Mob.MixProject do "guides/theming.md": [title: "Theming"], "guides/navigation.md": [title: "Navigation"], "guides/device_capabilities.md": [title: "Device Capabilities"], + "guides/permissions.md": [title: "Permissions"], "guides/native_extensions.md": [title: "Native Extensions (NIFs, features)"], "guides/dns_on_ios.md": [title: "DNS on iOS"], "guides/push_notifications.md": [title: "Push Notifications"], From 7d3f315f8ff057606394382b7cb689227091b482 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 10:00:46 -0600 Subject: [PATCH 063/254] Mob.Camera: live frame stream + shared AVCaptureSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- android/jni/mob_nif.zig | 26 +++ ios/mob_nif.m | 395 ++++++++++++++++++++++++++++++++++++--- lib/mob/camera.ex | 94 ++++++++++ src/mob_nif.erl | 6 + test/mob/camera_test.exs | 82 ++++++++ 5 files changed, 578 insertions(+), 25 deletions(-) create mode 100644 test/mob/camera_test.exs diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index ac12cab6..e65549fd 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2326,6 +2326,30 @@ export fn nif_camera_stop_preview( return erts.ok(env); } +// Live camera frame stream — Android implementation pending (needs +// Camera2 + ImageAnalysis wiring on the Kotlin side). Returns +// :unsupported for now so the iOS demo unblocks without breaking the +// Android build. Track in https://github.com/GenericJam/mob/issues +export fn nif_camera_start_frame_stream( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.atom(env, "unsupported"); +} + +export fn nif_camera_stop_frame_stream( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.atom(env, "unsupported"); +} + export fn nif_photos_pick( env: ?*erts.ErlNifEnv, argc: c_int, @@ -3266,6 +3290,8 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "camera_capture_video", .arity = 1, .fptr = nif_camera_capture_video, .flags = 0 }, .{ .name = "camera_start_preview", .arity = 1, .fptr = nif_camera_start_preview, .flags = 0 }, .{ .name = "camera_stop_preview", .arity = 0, .fptr = nif_camera_stop_preview, .flags = 0 }, + .{ .name = "camera_start_frame_stream", .arity = 1, .fptr = nif_camera_start_frame_stream, .flags = 0 }, + .{ .name = "camera_stop_frame_stream", .arity = 0, .fptr = nif_camera_stop_frame_stream, .flags = 0 }, .{ .name = "photos_pick", .arity = 2, .fptr = nif_photos_pick, .flags = 0 }, .{ .name = "files_pick", .arity = 1, .fptr = nif_files_pick, .flags = 0 }, .{ .name = "audio_start_recording", .arity = 1, .fptr = nif_audio_start_recording, .flags = 0 }, diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 4e9ddc87..e1df8bf0 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -31,6 +31,7 @@ #import "MobNode.h" #include "erl_nif.h" #import +#import #import #import #import @@ -2064,6 +2065,11 @@ static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc, // ── Permission request ──────────────────────────────────────────────────── +// Forward declaration — definition lives below the +// `MobLocationPermissionDelegate` class so it can reference its +// instance methods. C99 forbids implicit function declarations. +static void request_location_permission(ErlNifPid pid); + static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char cap[32]; if (!enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1)) @@ -2257,7 +2263,8 @@ - (void)locationManager:(CLLocationManager *)mgr didFailWithError:(NSError *)err // `handle_info({:location, :error, _}, _)` path catches it. - (void)locationManagerDidChangeAuthorization:(CLLocationManager *)mgr { CLAuthorizationStatus status = mgr.authorizationStatus; - if (status == kCLAuthorizationStatusNotDetermined) return; + if (status == kCLAuthorizationStatusNotDetermined) + return; if (status == kCLAuthorizationStatusAuthorizedWhenInUse || status == kCLAuthorizationStatusAuthorizedAlways) { return; @@ -2424,7 +2431,74 @@ static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv *env, int argc, const ERL // ── Camera preview ──────────────────────────────────────────────────────── -AVCaptureSession *g_preview_session = nil; +// One shared AVCaptureSession per app — iOS won't allow two sessions on +// the same physical camera, and a single session can carry multiple +// outputs (preview layer + AVCaptureVideoDataOutput). Both +// `start_preview` and `start_frame_stream` configure this same session; +// the serial queue serializes all mutation so they can be called in +// either order. +AVCaptureSession *g_preview_session = nil; // exported name preserved for SwiftUI +static AVCaptureDeviceInput *g_camera_input = nil; +static NSString *g_camera_facing = nil; +static dispatch_queue_t g_camera_queue = NULL; + +static dispatch_queue_t mob_camera_queue(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + g_camera_queue = dispatch_queue_create("io.mob.camera.config", DISPATCH_QUEUE_SERIAL); + }); + return g_camera_queue; +} + +// Configure session input for the requested facing. Idempotent — if the +// facing already matches, leaves the input alone. Must be called from +// the serial camera queue. +static BOOL mob_camera_ensure_session(NSString *facing) { + if (!g_preview_session) { + g_preview_session = [[AVCaptureSession alloc] init]; + g_preview_session.sessionPreset = AVCaptureSessionPresetHigh; + NSLog(@"[mob/camera] created shared AVCaptureSession"); + } + if (g_camera_input && [g_camera_facing isEqualToString:facing]) { + return YES; + } + + AVCaptureDevicePosition position = [facing isEqualToString:@"front"] + ? AVCaptureDevicePositionFront + : AVCaptureDevicePositionBack; + AVCaptureDevice *device = + [AVCaptureDevice defaultDeviceWithDeviceType:AVCaptureDeviceTypeBuiltInWideAngleCamera + mediaType:AVMediaTypeVideo + position:position]; + if (!device) { + NSLog(@"[mob/camera] no camera device for facing=%@", facing); + return NO; + } + NSError *err = nil; + AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&err]; + if (!input) { + NSLog(@"[mob/camera] AVCaptureDeviceInput failed: %@", err); + return NO; + } + + [g_preview_session beginConfiguration]; + if (g_camera_input) { + [g_preview_session removeInput:g_camera_input]; + g_camera_input = nil; + } + if ([g_preview_session canAddInput:input]) { + [g_preview_session addInput:input]; + g_camera_input = input; + g_camera_facing = [facing copy]; + NSLog(@"[mob/camera] added input facing=%@", facing); + } else { + NSLog(@"[mob/camera] canAddInput=NO for facing=%@", facing); + [g_preview_session commitConfiguration]; + return NO; + } + [g_preview_session commitConfiguration]; + return YES; +} static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; @@ -2442,31 +2516,14 @@ static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv *env, int argc, const ERL facing = @"front"; } - // Session setup and startRunning must run on a background queue (Apple requirement). - // After the session is running, update the shared global and notify the preview view - // on the main queue so SwiftUI can safely read g_preview_session. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - AVCaptureDevicePosition position = [facing isEqualToString:@"front"] - ? AVCaptureDevicePositionFront - : AVCaptureDevicePositionBack; - AVCaptureDevice *device = - [AVCaptureDevice defaultDeviceWithDeviceType:AVCaptureDeviceTypeBuiltInWideAngleCamera - mediaType:AVMediaTypeVideo - position:position]; - if (!device) - return; - AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil]; - if (!input) + dispatch_async(mob_camera_queue(), ^{ + if (!mob_camera_ensure_session(facing)) return; - AVCaptureSession *session = [[AVCaptureSession alloc] init]; - session.sessionPreset = AVCaptureSessionPresetHigh; - if ([session canAddInput:input]) - [session addInput:input]; - [session startRunning]; + if (!g_preview_session.isRunning) { + [g_preview_session startRunning]; + NSLog(@"[mob/camera] session startRunning (from preview)"); + } dispatch_async(dispatch_get_main_queue(), ^{ - if (g_preview_session) - [g_preview_session stopRunning]; - g_preview_session = session; [[NSNotificationCenter defaultCenter] postNotificationName:@"MobCameraSessionChanged" object:nil]; }); @@ -2489,6 +2546,292 @@ static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv *env, int argc, const ERL_ return enif_make_atom(env, "ok"); } +// ── Camera frame stream ─────────────────────────────────────────────────── +// Delivers per-frame pixel data to a BEAM process as messages of shape: +// +// {camera, frame, #{bytes, width, height, format, timestamp_ms, dropped}} +// +// The capture session here is independent of the preview session — they +// each own their own AVCaptureDeviceInput on the same camera device (an +// arrangement AVFoundation allows). This means start_frame_stream can run +// headlessly (no visible preview) and start_preview can run without ML +// inference. The two compose cleanly when both are active. +// +// vImage handles resize + format conversion on the capture queue so the +// BEAM mailbox never sees raw camera buffers. Late frames are discarded +// at the AVFoundation layer (alwaysDiscardsLateVideoFrames=YES is the +// iOS default and we leave it on); throttle_ms adds an additional +// software gate when callers want a slower delivery rate than the +// camera's native 30fps. + +@interface MobFrameDelegate : NSObject +@end + +@implementation MobFrameDelegate { + ErlNifPid _receiver_pid; + int _target_width; + int _target_height; + NSString *_format; + int _throttle_ms; + uint64_t _last_delivered_ms; + uint64_t _dropped_count; +} + +- (instancetype)initWithPid:(ErlNifPid)pid + width:(int)width + height:(int)height + format:(NSString *)format + throttleMs:(int)throttleMs { + if ((self = [super init])) { + _receiver_pid = pid; + _target_width = width; + _target_height = height; + _format = format; + _throttle_ms = throttleMs; + _last_delivered_ms = 0; + _dropped_count = 0; + } + return self; +} + +- (void)captureOutput:(AVCaptureOutput *)output + didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer + fromConnection:(AVCaptureConnection *)connection { + uint64_t now_ms = (uint64_t)([[NSDate date] timeIntervalSince1970] * 1000.0); + + // Software-side throttle: gate at most one frame per `throttle_ms`. + // Frames arriving faster get counted in `dropped` for visibility. + if (_throttle_ms > 0 && (now_ms - _last_delivered_ms) < (uint64_t)_throttle_ms) { + _dropped_count++; + return; + } + + CVPixelBufferRef pixbuf = CMSampleBufferGetImageBuffer(sampleBuffer); + if (!pixbuf) { + _dropped_count++; + return; + } + + CVPixelBufferLockBaseAddress(pixbuf, kCVPixelBufferLock_ReadOnly); + + size_t src_w = CVPixelBufferGetWidth(pixbuf); + size_t src_h = CVPixelBufferGetHeight(pixbuf); + void *src_base = CVPixelBufferGetBaseAddress(pixbuf); + size_t src_stride = CVPixelBufferGetBytesPerRow(pixbuf); + + // Center-crop the source to the destination aspect ratio so the + // resize doesn't squash a 16:9 camera frame into a 1:1 tensor. + // For a 1920×1080 source and a 640×640 destination: take a centered + // 1080×1080 square (cuts 420 px from each side), then scale to + // 640×640. + int dst_w = _target_width; + int dst_h = _target_height; + + double src_aspect = (double)src_w / (double)src_h; + double dst_aspect = (double)dst_w / (double)dst_h; + + size_t crop_x = 0, crop_y = 0, crop_w = src_w, crop_h = src_h; + if (src_aspect > dst_aspect) { + // Source is wider than destination — crop horizontally. + crop_w = (size_t)((double)src_h * dst_aspect); + crop_x = (src_w - crop_w) / 2; + } else if (src_aspect < dst_aspect) { + // Source is taller than destination — crop vertically. + crop_h = (size_t)((double)src_w / dst_aspect); + crop_y = (src_h - crop_h) / 2; + } + + // vImage source descriptor pointing at the (possibly-offset) crop region. + // Bytes per pixel for BGRA = 4. + vImage_Buffer vsrc = { + .data = (uint8_t *)src_base + (crop_y * src_stride) + (crop_x * 4), + .height = crop_h, + .width = crop_w, + .rowBytes = src_stride, + }; + + // Intermediate BGRA8 destination at the target size. + uint8_t *dst_bgra = malloc((size_t)dst_w * dst_h * 4); + vImage_Buffer vdst = { + .data = dst_bgra, + .height = (vImagePixelCount)dst_h, + .width = (vImagePixelCount)dst_w, + .rowBytes = (size_t)dst_w * 4, + }; + + vImageScale_ARGB8888(&vsrc, &vdst, NULL, kvImageHighQualityResampling); + + CVPixelBufferUnlockBaseAddress(pixbuf, kCVPixelBufferLock_ReadOnly); + + // Pack into the requested output format. + ErlNifEnv *msg_env = enif_alloc_env(); + ErlNifBinary out_bin; + + if ([_format isEqualToString:@"rgb_f32"]) { + size_t pixel_count = (size_t)dst_w * (size_t)dst_h; + enif_alloc_binary(pixel_count * 3 * sizeof(float), &out_bin); + float *out = (float *)out_bin.data; + + // vImage delivers BGRA8 in iOS-native channel order. Convert to + // interleaved RGB f32 in [0, 1]. Straight loop is fine — vImage + // doesn't ship a BGRA→RGB-interleaved-f32 single-call so this + // would otherwise be three passes (BGRA→RGBA→planar→combine). + // The single-pass loop is ~1ms on a 640×640 frame. + for (size_t i = 0; i < pixel_count; i++) { + uint8_t b = dst_bgra[i * 4 + 0]; + uint8_t g = dst_bgra[i * 4 + 1]; + uint8_t r = dst_bgra[i * 4 + 2]; + out[i * 3 + 0] = (float)r / 255.0f; + out[i * 3 + 1] = (float)g / 255.0f; + out[i * 3 + 2] = (float)b / 255.0f; + } + } else { + // :bgra_u8 — copy bytes directly. + enif_alloc_binary((size_t)dst_w * dst_h * 4, &out_bin); + memcpy(out_bin.data, dst_bgra, (size_t)dst_w * dst_h * 4); + } + free(dst_bgra); + + // Build the result map. Keys are atoms so the Elixir side gets + // %{bytes:, width:, height:, format:, timestamp_ms:, dropped:}. + ERL_NIF_TERM bytes_term = enif_make_binary(msg_env, &out_bin); + ERL_NIF_TERM map = enif_make_new_map(msg_env); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "bytes"), bytes_term, &map); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "width"), enif_make_int(msg_env, dst_w), + &map); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "height"), + enif_make_int(msg_env, dst_h), &map); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "format"), + enif_make_atom(msg_env, [_format UTF8String]), &map); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "timestamp_ms"), + enif_make_uint64(msg_env, now_ms), &map); + enif_make_map_put(msg_env, map, enif_make_atom(msg_env, "dropped"), + enif_make_uint64(msg_env, _dropped_count), &map); + + ERL_NIF_TERM tagged = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "camera"), + enif_make_atom(msg_env, "frame"), map); + + // enif_send is documented thread-safe; this delegate callback runs + // on the capture session's serial queue, not the BEAM scheduler. + // Passing NULL for caller_env is the standard pattern from a + // non-BEAM thread. + enif_send(NULL, &_receiver_pid, msg_env, tagged); + enif_free_env(msg_env); + + _last_delivered_ms = now_ms; + _dropped_count = 0; +} + +@end + +// Frame stream output + delegate attach to the shared g_preview_session. +// AVCaptureVideoDataOutput does NOT retain its delegate, so g_frame_delegate +// is the canonical strong reference that keeps it alive. +static AVCaptureVideoDataOutput *g_frame_output = nil; +static MobFrameDelegate *g_frame_delegate = nil; +static dispatch_queue_t g_frame_delivery_queue = NULL; + +static ERL_NIF_TERM nif_camera_start_frame_stream(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) { + return enif_make_badarg(env); + } + + NSString *json = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + NSDictionary *opts = + [NSJSONSerialization JSONObjectWithData:[json dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + + int target_w = [(opts[@"width"] ?: @640) intValue]; + int target_h = [(opts[@"height"] ?: @640) intValue]; + NSString *facing = [opts[@"facing"] isEqualToString:@"front"] ? @"front" : @"back"; + NSString *format = [opts[@"format"] isEqualToString:@"bgra_u8"] ? @"bgra_u8" : @"rgb_f32"; + int throttle_ms = [(opts[@"throttle_ms"] ?: @0) intValue]; + + // Cap pixel count to keep mailbox bounded. ~4 MP = 2048×2048. + if ((int64_t)target_w * (int64_t)target_h > 4 * 1024 * 1024) { + target_w = 2048; + target_h = 2048; + } + + ErlNifPid caller_pid; + enif_self(env, &caller_pid); + + NSLog(@"[mob/camera] start_frame_stream w=%d h=%d facing=%@ format=%@ throttle=%d", target_w, + target_h, facing, format, throttle_ms); + + if (!g_frame_delivery_queue) { + g_frame_delivery_queue = + dispatch_queue_create("io.mob.camera.frame_delivery", DISPATCH_QUEUE_SERIAL); + } + + dispatch_async(mob_camera_queue(), ^{ + if (!mob_camera_ensure_session(facing)) { + NSLog(@"[mob/camera] ensure_session failed"); + return; + } + + [g_preview_session beginConfiguration]; + if (g_frame_output) { + [g_preview_session removeOutput:g_frame_output]; + g_frame_output = nil; + g_frame_delegate = nil; + } + + AVCaptureVideoDataOutput *output = [[AVCaptureVideoDataOutput alloc] init]; + output.videoSettings = @{(id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA)}; + output.alwaysDiscardsLateVideoFrames = YES; + + MobFrameDelegate *delegate = [[MobFrameDelegate alloc] initWithPid:caller_pid + width:target_w + height:target_h + format:format + throttleMs:throttle_ms]; + // Per Apple: setSampleBufferDelegate:queue: does NOT retain the + // delegate. Hold our own strong ref in g_frame_delegate. + [output setSampleBufferDelegate:delegate queue:g_frame_delivery_queue]; + + if ([g_preview_session canAddOutput:output]) { + [g_preview_session addOutput:output]; + g_frame_output = output; + g_frame_delegate = delegate; + NSLog(@"[mob/camera] added AVCaptureVideoDataOutput"); + } else { + NSLog(@"[mob/camera] canAddOutput=NO"); + } + [g_preview_session commitConfiguration]; + + if (!g_preview_session.isRunning) { + [g_preview_session startRunning]; + NSLog(@"[mob/camera] session startRunning (from frame_stream)"); + } else { + NSLog(@"[mob/camera] session already running"); + } + }); + + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_camera_stop_frame_stream(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + dispatch_async(mob_camera_queue(), ^{ + if (g_frame_output) { + [g_preview_session beginConfiguration]; + [g_preview_session removeOutput:g_frame_output]; + [g_preview_session commitConfiguration]; + g_frame_output = nil; + g_frame_delegate = nil; + NSLog(@"[mob/camera] stop_frame_stream removed output"); + } + }); + return enif_make_atom(env, "ok"); +} + // ── Photo library picker ────────────────────────────────────────────────── @interface MobPhotosDelegate : NSObject @@ -5871,6 +6214,8 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"camera_capture_video", 1, nif_camera_capture_video, 0}, {"camera_start_preview", 1, nif_camera_start_preview, 0}, {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, + {"camera_start_frame_stream", 1, nif_camera_start_frame_stream, 0}, + {"camera_stop_frame_stream", 0, nif_camera_stop_frame_stream, 0}, {"photos_pick", 2, nif_photos_pick, 0}, {"files_pick", 1, nif_files_pick, 0}, {"audio_start_recording", 1, nif_audio_start_recording, 0}, diff --git a/lib/mob/camera.ex b/lib/mob/camera.ex index 706aaffb..96b9dddb 100644 --- a/lib/mob/camera.ex +++ b/lib/mob/camera.ex @@ -19,6 +19,20 @@ defmodule Mob.Camera do The `path` is a local temp file. Copy it elsewhere before the next capture. iOS: `UIImagePickerController`. Android: `TakePicture` / `CaptureVideo` activity contracts. + + ## Live frame stream + + For real-time work (object detection, AR, custom filters) `start_frame_stream/2` + delivers per-frame pixel data as messages: + + handle_info({:camera, :frame, %{bytes: bin, width: w, height: h, + format: :rgb_f32, + timestamp_ms: t, dropped: n}}, socket) + + The native side handles resize + format conversion (vImage on iOS) so + the BEAM never sees raw camera buffers. Late frames are dropped on + the native side — the BEAM mailbox can't unbounded-grow if your + receiver lags behind the camera's 30 fps cadence. """ @doc """ @@ -67,4 +81,84 @@ defmodule Mob.Camera do :mob_nif.camera_stop_preview() socket end + + @doc """ + Start streaming camera frames to the calling process. Frames arrive + as messages of shape: + + handle_info({:camera, :frame, %{ + bytes: binary(), # pixel data, format-dependent + width: non_neg_integer(), + height: non_neg_integer(), + format: :rgb_f32 | :bgra_u8, + timestamp_ms: non_neg_integer(), + dropped: non_neg_integer() # frames skipped since last delivery + }}, socket) + + ## Options + + * `:width`, `:height` — target frame size in pixels. Defaults to + `640` × `640` (YOLO-friendly). Pass `nil` for both to receive the + camera's native resolution. Mismatched aspect ratios are + center-cropped on the long axis before scaling. Capped at ~4 MP + to keep the BEAM mailbox bounded. + + * `:format` — pixel format. One of: + - `:rgb_f32` (default) — interleaved RGB floats normalised to + `[0.0, 1.0]`. Byte size: `width * height * 3 * 4`. Ready for + `Nx.from_binary(bin, :f32, ...) |> Nx.reshape({1, h, w, 3})`. + - `:bgra_u8` — raw 32-bit BGRA bytes, native iOS pixel layout. + Byte size: `width * height * 4`. 4× smaller than `:rgb_f32`; + useful for forwarding to another NIF or doing custom + preprocessing. + + * `:facing` — `:back` (default) or `:front`. Same camera the + preview uses; calling `start_frame_stream/2` alone will activate + the capture session without a visible preview. + + * `:throttle_ms` — minimum interval between deliveries (default + `0`). Native-side throttle, complementary to the OS's late-frame + drop. Use `throttle_ms: 100` for 10 Hz delivery when full + camera-rate inference isn't needed. + + ## Notes + + Returns the socket immediately; frames begin arriving asynchronously + once the OS has activated the capture session (typically <100 ms). + Receiver is the **calling process** at the time of invocation — + call from a `Mob.Screen` callback (mount, handle_info), not from a + task or genserver running elsewhere. + """ + @spec start_frame_stream(Mob.Socket.t(), keyword()) :: Mob.Socket.t() + def start_frame_stream(socket, opts \\ []) do + :mob_nif.camera_start_frame_stream(:json.encode(frame_stream_opts(opts))) + socket + end + + @doc """ + Build the option map passed to `camera_start_frame_stream/1`. Pure + function exposed so tests can pin defaults + serialisation without + going through the NIF. + """ + @spec frame_stream_opts(keyword()) :: map() + def frame_stream_opts(opts) do + %{ + "width" => Keyword.get(opts, :width, 640), + "height" => Keyword.get(opts, :height, 640), + "format" => Keyword.get(opts, :format, :rgb_f32) |> Atom.to_string(), + "facing" => Keyword.get(opts, :facing, :back) |> Atom.to_string(), + "throttle_ms" => Keyword.get(opts, :throttle_ms, 0) + } + end + + @doc """ + Stop the camera frame stream. Safe to call when no stream is + active. The visible preview (if `start_preview/2` was called + separately) is left untouched. + """ + @spec stop_frame_stream(Mob.Socket.t()) :: Mob.Socket.t() + def stop_frame_stream(socket) do + :mob_nif.camera_stop_frame_stream() + socket + end end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 722932d2..e5692d73 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -30,6 +30,8 @@ camera_capture_video/1, camera_start_preview/1, camera_stop_preview/0, + camera_start_frame_stream/1, + camera_stop_frame_stream/0, %% Photo library photos_pick/2, %% File picker @@ -133,6 +135,8 @@ camera_capture_video/1, camera_start_preview/1, camera_stop_preview/0, + camera_start_frame_stream/1, + camera_stop_frame_stream/0, photos_pick/2, files_pick/1, audio_start_recording/1, @@ -230,6 +234,8 @@ camera_capture_photo(_Quality) -> erlang:nif_error(not_loaded). camera_capture_video(_MaxDuration) -> erlang:nif_error(not_loaded). camera_start_preview(_OptsJson) -> erlang:nif_error(not_loaded). camera_stop_preview() -> erlang:nif_error(not_loaded). +camera_start_frame_stream(_OptsJson) -> erlang:nif_error(not_loaded). +camera_stop_frame_stream() -> erlang:nif_error(not_loaded). photos_pick(_Max, _Types) -> erlang:nif_error(not_loaded). files_pick(_MimeTypes) -> erlang:nif_error(not_loaded). audio_start_recording(_OptsJson) -> erlang:nif_error(not_loaded). diff --git a/test/mob/camera_test.exs b/test/mob/camera_test.exs new file mode 100644 index 00000000..59e1eeb6 --- /dev/null +++ b/test/mob/camera_test.exs @@ -0,0 +1,82 @@ +defmodule Mob.CameraTest do + use ExUnit.Case, async: true + + alias Mob.Camera + + describe "frame_stream_opts/1" do + test "defaults: 640×640 rgb_f32 back camera, no throttle" do + assert Camera.frame_stream_opts([]) == %{ + "width" => 640, + "height" => 640, + "format" => "rgb_f32", + "facing" => "back", + "throttle_ms" => 0 + } + end + + test "width / height override" do + opts = Camera.frame_stream_opts(width: 320, height: 240) + assert opts["width"] == 320 + assert opts["height"] == 240 + end + + test ":bgra_u8 format is passed through as the string \"bgra_u8\"" do + opts = Camera.frame_stream_opts(format: :bgra_u8) + assert opts["format"] == "bgra_u8" + end + + test ":front facing is passed through as the string \"front\"" do + opts = Camera.frame_stream_opts(facing: :front) + assert opts["facing"] == "front" + end + + test "throttle_ms is passed through as an integer" do + opts = Camera.frame_stream_opts(throttle_ms: 100) + assert opts["throttle_ms"] == 100 + end + + test "keys are strings, matching the rest of the NIF JSON surface" do + opts = Camera.frame_stream_opts([]) + # Audio + start_preview use string keys for their JSON-encoded + # NIF args; frame_stream_opts should follow the same convention so + # the iOS-side NSJSONSerialization deserialises a consistent shape. + for key <- ["width", "height", "format", "facing", "throttle_ms"] do + assert Map.has_key?(opts, key), "expected key #{inspect(key)}" + end + + for atom <- [:width, :height, :format, :facing, :throttle_ms] do + refute Map.has_key?(opts, atom), "found atom key #{inspect(atom)}" + end + end + + test "every option is independently overridable" do + opts = + Camera.frame_stream_opts( + width: 1280, + height: 720, + format: :bgra_u8, + facing: :front, + throttle_ms: 33 + ) + + assert opts == %{ + "width" => 1280, + "height" => 720, + "format" => "bgra_u8", + "facing" => "front", + "throttle_ms" => 33 + } + end + + test "serialises to JSON cleanly (this is what hits the NIF)" do + # The NIF receives the JSON-encoded result, so make sure the map + # round-trips through :json without losing data. Any future + # option that's not JSON-serialisable would fail here. + opts = Camera.frame_stream_opts([]) + json = :json.encode(opts) |> IO.iodata_to_binary() + + decoded = :json.decode(json) + assert decoded == opts + end + end +end From 8ce0c424a583e3aa9b2fe53ace779768ed586616 Mon Sep 17 00:00:00 2001 From: HeroesLament Date: Fri, 15 May 2026 08:39:31 -0800 Subject: [PATCH 064/254] Mob.Bt: Bluetooth Classic peripheral (HFP/SPP/HID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/jni/mob_beam.h | 61 ++ android/jni/mob_nif.zig | 1400 ++++++++++++++++++++++++++++++++++++++- ios/mob_nif.m | 188 ++++++ lib/mob/bt.ex | 186 ++++++ lib/mob/bt/hfp.ex | 179 +++++ lib/mob/bt/hid.ex | 99 +++ lib/mob/bt/spp.ex | 95 +++ src/mob_nif.erl | 51 ++ 8 files changed, 2258 insertions(+), 1 deletion(-) create mode 100644 lib/mob/bt.ex create mode 100644 lib/mob/bt/hfp.ex create mode 100644 lib/mob/bt/hid.ex create mode 100644 lib/mob/bt/spp.ex diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 0343f86f..4eb79a94 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -140,4 +140,65 @@ void mob_send_component_event(int handle, const char *event, const char *payload // `scheme` must be "light" or "dark". void mob_send_color_scheme_changed(const char *scheme); + +// mob_beam.h additions for Mob.Bt +// +// Append these to the existing mob_beam.h, after the +// mob_deliver_vendor_usb_* block. Order matches the +// `pub export fn mob_deliver_bt_*` definitions in mob_nif.zig. +// +// All BT deliveries take a jlong pid (ErlNifPid round-tripped through +// Kotlin) and post a 4-tuple `{:bt|:bt_hfp|:bt_spp|:bt_hid, tag, session_or_nil, payload}` +// to that pid. Payload shape varies by event; see mob_nif.zig for details. + +// ── Discovery (no-payload 2-tuples) ────────────────────────────────────── +void mob_deliver_bt_discovery_started(jlong pid); +void mob_deliver_bt_discovery_finished(jlong pid); +void mob_deliver_bt_discovery_cancelled(jlong pid); + +// ── Discovery / pairing events (3-tuples, no session) ──────────────────── +void mob_deliver_bt_discovered(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_pair_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_unpaired(jlong pid, const char *address); +void mob_deliver_bt_error(jlong pid, const char *reason); + +// ── Legacy JSON paired-devices envelope (compat with older mob_new templates) ── +void mob_deliver_bt_paired_devices(jlong pid, const char *json); + +// ── Paired-list streaming (begin / entry / finish) ────────────────────── +// Kotlin invokes begin, then 0..N entry calls, then finish. The finish +// call emits a single `{:bt, :paired_list, list}` to the originating pid. +void mob_deliver_bt_paired_list_begin(jlong pid); +void mob_deliver_bt_paired_list_entry(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired_list_finish(jlong pid); + +// ── HFP profile (8 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hfp_connecting(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_hfp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hfp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hfp_vendor_subscribed(jlong pid, int session); +void mob_deliver_bt_hfp_vendor_at(jlong pid, int session, const char *cmd, int cmd_type, + const char *args, const char *address); +void mob_deliver_bt_hfp_sco_started(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_sco_stopped(jlong pid, int session); +void mob_deliver_bt_hfp_sco_audio(jlong pid, int session, const char *pcm, size_t len); +void mob_deliver_bt_hfp_error(jlong pid, int session, const char *reason); + +// ── SPP profile (6 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_spp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_spp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_spp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_spp_data(jlong pid, int session, const char *bytes, size_t len); +void mob_deliver_bt_spp_written(jlong pid, int session, int size); +void mob_deliver_bt_spp_error(jlong pid, int session, const char *reason); + +// ── HID profile (5 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hid_connected(jlong pid, int session, const char *address); +void mob_deliver_bt_hid_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hid_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hid_input(jlong pid, int session, int type, int code, int value); +void mob_deliver_bt_hid_raw_report(jlong pid, int session, const char *bytes, size_t len); + #endif // MOB_BEAM_H diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index ac12cab6..e4e41ddb 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -210,6 +210,23 @@ pub const BridgeMethods = extern struct { vendor_usb_start_reading: jni.JMethodID = null, vendor_usb_stop_reading: jni.JMethodID = null, vendor_usb_close: jni.JMethodID = null, + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + bt_list_paired: jni.JMethodID = null, + bt_start_discovery: jni.JMethodID = null, + bt_cancel_discovery: jni.JMethodID = null, + bt_pair: jni.JMethodID = null, + bt_unpair: jni.JMethodID = null, + bt_disconnect: jni.JMethodID = null, + bt_hfp_connect: jni.JMethodID = null, + bt_hfp_subscribe_vendor_at: jni.JMethodID = null, + bt_hfp_send_vendor_at: jni.JMethodID = null, + bt_hfp_start_sco: jni.JMethodID = null, + bt_hfp_stop_sco: jni.JMethodID = null, + bt_hfp_send_audio: jni.JMethodID = null, + bt_spp_connect: jni.JMethodID = null, + bt_spp_write: jni.JMethodID = null, + bt_hid_connect: jni.JMethodID = null, + bt_hid_subscribe_raw: jni.JMethodID = null, }; /// Exported with C ABI so mob_nif.c (and beam_jni.c for the senders in @@ -3080,6 +3097,1343 @@ export fn nif_vendor_usb_close( return erts.ok(env); } +// ═════════════════════════════════════════════════════════════════════════ +// Mob.Bt (Bluetooth Classic) — atom cache + delivery functions + NIFs +// ═════════════════════════════════════════════════════════════════════════ + +const MobBtAtoms = struct { + // Channel atoms + bt: erts.ERL_NIF_TERM = 0, + bt_hfp: erts.ERL_NIF_TERM = 0, + bt_spp: erts.ERL_NIF_TERM = 0, + bt_hid: erts.ERL_NIF_TERM = 0, + + // Discovery / pairing tags + discovery_started: erts.ERL_NIF_TERM = 0, + discovery_finished: erts.ERL_NIF_TERM = 0, + discovery_cancelled: erts.ERL_NIF_TERM = 0, + discovered: erts.ERL_NIF_TERM = 0, + paired_list: erts.ERL_NIF_TERM = 0, + paired: erts.ERL_NIF_TERM = 0, + pair_failed: erts.ERL_NIF_TERM = 0, + unpaired: erts.ERL_NIF_TERM = 0, + err: erts.ERL_NIF_TERM = 0, + + // Profile lifecycle tags + connecting: erts.ERL_NIF_TERM = 0, + connected: erts.ERL_NIF_TERM = 0, + connect_failed: erts.ERL_NIF_TERM = 0, + disconnected: erts.ERL_NIF_TERM = 0, + + // HFP-specific + vendor_subscribed: erts.ERL_NIF_TERM = 0, + vendor_at: erts.ERL_NIF_TERM = 0, + sco_started: erts.ERL_NIF_TERM = 0, + sco_stopped: erts.ERL_NIF_TERM = 0, + sco_audio: erts.ERL_NIF_TERM = 0, + + // SPP-specific + data: erts.ERL_NIF_TERM = 0, + written: erts.ERL_NIF_TERM = 0, + + // HID-specific + input: erts.ERL_NIF_TERM = 0, + raw_report: erts.ERL_NIF_TERM = 0, + + // Map keys + k_address: erts.ERL_NIF_TERM = 0, + k_name: erts.ERL_NIF_TERM = 0, + k_bonded: erts.ERL_NIF_TERM = 0, + k_reason: erts.ERL_NIF_TERM = 0, + k_cmd: erts.ERL_NIF_TERM = 0, + k_cmd_type: erts.ERL_NIF_TERM = 0, + k_args: erts.ERL_NIF_TERM = 0, + k_size: erts.ERL_NIF_TERM = 0, + k_type: erts.ERL_NIF_TERM = 0, + k_code: erts.ERL_NIF_TERM = 0, + k_value: erts.ERL_NIF_TERM = 0, + + // Constants + nil_atom: erts.ERL_NIF_TERM = 0, + true_atom: erts.ERL_NIF_TERM = 0, + false_atom: erts.ERL_NIF_TERM = 0, +}; + +var mob_bt_atoms: MobBtAtoms = .{}; + +/// Initialise the BT atom cache. Call from `nifLoad` once before any +/// BT NIF or deliver function fires. +pub fn mobBtAtomsInit(env: ?*erts.ErlNifEnv) void { + mob_bt_atoms.bt = erts.atom(env, "bt"); + mob_bt_atoms.bt_hfp = erts.atom(env, "bt_hfp"); + mob_bt_atoms.bt_spp = erts.atom(env, "bt_spp"); + mob_bt_atoms.bt_hid = erts.atom(env, "bt_hid"); + + mob_bt_atoms.discovery_started = erts.atom(env, "discovery_started"); + mob_bt_atoms.discovery_finished = erts.atom(env, "discovery_finished"); + mob_bt_atoms.discovery_cancelled = erts.atom(env, "discovery_cancelled"); + mob_bt_atoms.discovered = erts.atom(env, "discovered"); + mob_bt_atoms.paired_list = erts.atom(env, "paired_list"); + mob_bt_atoms.paired = erts.atom(env, "paired"); + mob_bt_atoms.pair_failed = erts.atom(env, "pair_failed"); + mob_bt_atoms.unpaired = erts.atom(env, "unpaired"); + mob_bt_atoms.err = erts.atom(env, "error"); + + mob_bt_atoms.connecting = erts.atom(env, "connecting"); + mob_bt_atoms.connected = erts.atom(env, "connected"); + mob_bt_atoms.connect_failed = erts.atom(env, "connect_failed"); + mob_bt_atoms.disconnected = erts.atom(env, "disconnected"); + + mob_bt_atoms.vendor_subscribed = erts.atom(env, "vendor_subscribed"); + mob_bt_atoms.vendor_at = erts.atom(env, "vendor_at"); + mob_bt_atoms.sco_started = erts.atom(env, "sco_started"); + mob_bt_atoms.sco_stopped = erts.atom(env, "sco_stopped"); + mob_bt_atoms.sco_audio = erts.atom(env, "sco_audio"); + + mob_bt_atoms.data = erts.atom(env, "data"); + mob_bt_atoms.written = erts.atom(env, "written"); + + mob_bt_atoms.input = erts.atom(env, "input"); + mob_bt_atoms.raw_report = erts.atom(env, "raw_report"); + + mob_bt_atoms.k_address = erts.atom(env, "address"); + mob_bt_atoms.k_name = erts.atom(env, "name"); + mob_bt_atoms.k_bonded = erts.atom(env, "bonded"); + mob_bt_atoms.k_reason = erts.atom(env, "reason"); + mob_bt_atoms.k_cmd = erts.atom(env, "cmd"); + mob_bt_atoms.k_cmd_type = erts.atom(env, "cmd_type"); + mob_bt_atoms.k_args = erts.atom(env, "args"); + mob_bt_atoms.k_size = erts.atom(env, "size"); + mob_bt_atoms.k_type = erts.atom(env, "type"); + mob_bt_atoms.k_code = erts.atom(env, "code"); + mob_bt_atoms.k_value = erts.atom(env, "value"); + + mob_bt_atoms.nil_atom = erts.atom(env, "nil"); + mob_bt_atoms.true_atom = erts.atom(env, "true"); + mob_bt_atoms.false_atom = erts.atom(env, "false"); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (3) Term constructors — mirror the C mob_bt_make_* helpers +// ═════════════════════════════════════════════════════════════════════════ + +/// Convert a C string into an Erlang binary term. Empty input → empty +/// binary (NOT a `<<>>` atom dance). Atoms cache must be initialised. +fn mobBtMakeBinaryStr(env: ?*erts.ErlNifEnv, s: ?[*:0]const u8) erts.ERL_NIF_TERM { + const len: usize = if (s) |p| jni.strlen(p) else 0; + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + if (len > 0) { + if (s) |p| @memcpy(bin.data[0..len], p[0..len]); + } + return erts.enif_make_binary(env, &bin); +} + +/// Build a binary term from arbitrary bytes (PCM audio, raw HID reports, +/// SPP byte streams, etc). +fn mobBtMakeBinaryBytes(env: ?*erts.ErlNifEnv, bytes: ?[*]const u8, len: usize) erts.ERL_NIF_TERM { + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + if (len > 0) { + if (bytes) |p| @memcpy(bin.data[0..len], p[0..len]); + } + return erts.enif_make_binary(env, &bin); +} + +/// Build `%{address: <<...>>, name: <<...>>, bonded: bool}`. +fn mobBtMakeDeviceMap(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, name: ?[*:0]const u8, bonded: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_address, + mob_bt_atoms.k_name, + mob_bt_atoms.k_bonded, + }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + mobBtMakeBinaryStr(env, name), + if (bonded != 0) mob_bt_atoms.true_atom else mob_bt_atoms.false_atom, + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>}`. +fn mobBtMakeAddressOnly(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_address}; + const vals = [_]erts.ERL_NIF_TERM{mobBtMakeBinaryStr(env, address)}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>, name: <<...>>}`. +fn mobBtMakeAddressName(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, name: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ mob_bt_atoms.k_address, mob_bt_atoms.k_name }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + mobBtMakeBinaryStr(env, name), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>, reason: :reason_atom}`. Reason defaults to +/// `:unknown` for null Kotlin strings — never explodes on missing data. +fn mobBtMakeAddressReason(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, reason: ?[*:0]const u8) erts.ERL_NIF_TERM { + const reason_atom = if (reason) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const keys = [_]erts.ERL_NIF_TERM{ mob_bt_atoms.k_address, mob_bt_atoms.k_reason }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + reason_atom, + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{reason: :reason_atom}`. +fn mobBtMakeReasonOnly(env: ?*erts.ErlNifEnv, reason: ?[*:0]const u8) erts.ERL_NIF_TERM { + const reason_atom = if (reason) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_reason}; + const vals = [_]erts.ERL_NIF_TERM{reason_atom}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{cmd: <<...>>, cmd_type: int, args: <<...>>, address: <<...>>}` +/// for vendor AT events. cmd_type is the HFP AT command type code. +fn mobBtMakeVendorAtMap(env: ?*erts.ErlNifEnv, cmd: ?[*:0]const u8, cmd_type: c_int, args: ?[*:0]const u8, address: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_cmd, + mob_bt_atoms.k_cmd_type, + mob_bt_atoms.k_args, + mob_bt_atoms.k_address, + }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, cmd), + erts.enif_make_int(env, cmd_type), + mobBtMakeBinaryStr(env, args), + mobBtMakeBinaryStr(env, address), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{size: int}` for write-completion events. +fn mobBtMakeSizeMap(env: ?*erts.ErlNifEnv, size: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_size}; + const vals = [_]erts.ERL_NIF_TERM{erts.enif_make_int(env, size)}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{type: int, code: int, value: int}` for HID input events. +/// Matches the Linux evdev struct input_event triple. +fn mobBtMakeInputMap(env: ?*erts.ErlNifEnv, ev_type: c_int, code: c_int, value: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_type, + mob_bt_atoms.k_code, + mob_bt_atoms.k_value, + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_int(env, ev_type), + erts.enif_make_int(env, code), + erts.enif_make_int(env, value), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +// ═════════════════════════════════════════════════════════════════════════ +// (4) Paired-list streaming accumulator +// ═════════════════════════════════════════════════════════════════════════ +// +// The Kotlin side streams paired devices one at a time (begin → 0..N +// entries → finish). We need a stable per-pid buffer to accumulate entries +// in, since Kotlin can interleave streams from concurrent callers. +// +// 16 buckets × 128 max entries each. Slot lookup/insert/remove holds the +// table mutex; enif_send happens AFTER the mutex is released so a slow +// consumer doesn't block other accumulators. + +const MOB_BT_PAIRED_BUCKETS: usize = 16; +const MOB_BT_PAIRED_MAX_ENTRIES: usize = 128; +const MOB_BT_ADDR_MAX: usize = 24; // "00:11:22:33:44:55" + null + slop +const MOB_BT_NAME_MAX: usize = 248; // BT spec max friendly name + null + +const MobBtPairedEntry = extern struct { + address: [MOB_BT_ADDR_MAX]u8, + name: [MOB_BT_NAME_MAX]u8, + bonded: c_int, +}; + +const MobBtPairedSlot = extern struct { + pid_long: jni.JLong, + in_use: c_int, + count: usize, + entries: [MOB_BT_PAIRED_MAX_ENTRIES]MobBtPairedEntry, +}; + +var mob_bt_paired_slots: [MOB_BT_PAIRED_BUCKETS]MobBtPairedSlot = blk: { + var buf: [MOB_BT_PAIRED_BUCKETS]MobBtPairedSlot = undefined; + for (&buf) |*s| s.* = std.mem.zeroes(MobBtPairedSlot); + break :blk buf; +}; +var mob_bt_paired_mutex: ?*erts.ErlNifMutex = null; + +/// Initialise the paired-list accumulator. Returns 0 on success, -1 on +/// mutex-create failure. Call from `nifLoad`. +pub fn mobBtPairedInit() c_int { + mob_bt_paired_mutex = erts.enif_mutex_create("mob_bt_paired_mutex") orelse return -1; + return 0; +} + +/// Find an in-use slot matching `pid_long`. Must hold the mutex. +fn mobBtPairedFindLocked(pid_long: jni.JLong) ?*MobBtPairedSlot { + for (&mob_bt_paired_slots) |*s| { + if (s.in_use != 0 and s.pid_long == pid_long) return s; + } + return null; +} + +/// Claim the first free slot for `pid_long`, OR — if `pid_long` already +/// has a slot — reset it for a new accumulation cycle. Must hold mutex. +fn mobBtPairedClaimLocked(pid_long: jni.JLong) ?*MobBtPairedSlot { + if (mobBtPairedFindLocked(pid_long)) |existing| { + existing.count = 0; + return existing; + } + for (&mob_bt_paired_slots) |*s| { + if (s.in_use == 0) { + s.in_use = 1; + s.pid_long = pid_long; + s.count = 0; + return s; + } + } + return null; // all slots in use — drop this accumulation +} + +/// Mark a slot free. Must hold mutex. +fn mobBtPairedReleaseLocked(slot: *MobBtPairedSlot) void { + slot.in_use = 0; + slot.pid_long = 0; + slot.count = 0; +} + +// ═════════════════════════════════════════════════════════════════════════ +// (5) BT envelope helper — 4-tuple {:bt, tag, session_or_nil, payload} +// ═════════════════════════════════════════════════════════════════════════ +// +// All BT deliveries share this shape. Session is either an integer +// (profile event) or `:nil` (discovery / pairing event). Channel atom is +// `:bt` for discovery/pairing/error, `:bt_hfp` / `:bt_spp` / `:bt_hid` +// for profile-scoped events. + +/// Return :nil or an integer for the session slot. +inline fn btSessionTerm(env: ?*erts.ErlNifEnv, session: c_int) erts.ERL_NIF_TERM { + return if (session < 0) mob_bt_atoms.nil_atom else erts.enif_make_int(env, session); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (6) Delivery functions — `mob_deliver_bt_*` exports +// ═════════════════════════════════════════════════════════════════════════ +// +// Called from beam_jni.c's Java_..._MobBridge_nativeDeliverBt* thunks when +// Kotlin emits BT events. Each builds the typed envelope tuple and posts +// it to `pid_long` (an ErlNifPid round-tripped through Kotlin as a +// jlong). +// +// 33 functions total. Same structural shape as the VendorUsb deliveries: +// alloc env, build msg, send, free env. No mutex contention except the +// paired-list trio (begin / entry / finish), which holds the accumulator +// mutex briefly. + +// ── Discovery (2-tuples — no payload) ────────────────────────────────── + +pub export fn mob_deliver_bt_discovery_started(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_started, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_discovery_finished(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_finished, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_discovery_cancelled(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_cancelled, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Discovery / pairing (3-tuples — no session) ──────────────────────── + +pub export fn mob_deliver_bt_discovered( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovered, + mobBtMakeDeviceMap(env, address, name, bonded), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_paired( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.paired, + mobBtMakeDeviceMap(env, address, name, bonded), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_pair_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.pair_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_unpaired(pid_long: jni.JLong, address: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.unpaired, + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_error(pid_long: jni.JLong, reason: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.err, + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Legacy JSON paired-devices (unused by current Elixir API but kept +// for compat with Kotlin templates that pre-date the streamed paired +// list accumulator). Just shoves the JSON binary in as the payload. + +pub export fn mob_deliver_bt_paired_devices(pid_long: jni.JLong, json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + erts.atom(env, "paired_devices_json"), + mob_bt_atoms.nil_atom, + mobBtMakeBinaryStr(env, json), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Paired-list streaming (begin / entry / finish) ───────────────────── + +pub export fn mob_deliver_bt_paired_list_begin(pid_long: jni.JLong) callconv(.c) void { + erts.enif_mutex_lock(mob_bt_paired_mutex); + _ = mobBtPairedClaimLocked(pid_long); + erts.enif_mutex_unlock(mob_bt_paired_mutex); +} + +pub export fn mob_deliver_bt_paired_list_entry( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + erts.enif_mutex_lock(mob_bt_paired_mutex); + defer erts.enif_mutex_unlock(mob_bt_paired_mutex); + + const slot = mobBtPairedFindLocked(pid_long) orelse return; + if (slot.count >= MOB_BT_PAIRED_MAX_ENTRIES) return; + + const entry = &slot.entries[slot.count]; + if (address) |a| { + const a_len = jni.strlen(a); + const a_copy = @min(a_len, entry.address.len - 1); + @memcpy(entry.address[0..a_copy], a[0..a_copy]); + entry.address[a_copy] = 0; + } else { + entry.address[0] = 0; + } + if (name) |n| { + const n_len = jni.strlen(n); + const n_copy = @min(n_len, entry.name.len - 1); + @memcpy(entry.name[0..n_copy], n[0..n_copy]); + entry.name[n_copy] = 0; + } else { + entry.name[0] = 0; + } + entry.bonded = if (bonded != 0) 1 else 0; + slot.count += 1; +} + +pub export fn mob_deliver_bt_paired_list_finish(pid_long: jni.JLong) callconv(.c) void { + // Snapshot under lock, then release before any term allocation. + var snapshot: MobBtPairedSlot = undefined; + + erts.enif_mutex_lock(mob_bt_paired_mutex); + const slot = mobBtPairedFindLocked(pid_long); + if (slot == null) { + erts.enif_mutex_unlock(mob_bt_paired_mutex); + return; + } + snapshot = slot.?.*; + mobBtPairedReleaseLocked(slot.?); + erts.enif_mutex_unlock(mob_bt_paired_mutex); + + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + var list = erts.enif_make_list(env, 0); + var i: usize = snapshot.count; + while (i > 0) { + i -= 1; + const entry = &snapshot.entries[i]; + const addr_ptr: [*:0]const u8 = @ptrCast(&entry.address); + const name_ptr: [*:0]const u8 = @ptrCast(&entry.name); + const dev = mobBtMakeDeviceMap(env, addr_ptr, name_ptr, entry.bonded); + list = erts.enif_make_list_cell(env, dev, list); + } + + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.paired_list, + list, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── HFP profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_hfp_connecting( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connecting, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, + name: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressName(env, address, name), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_vendor_subscribed(pid_long: jni.JLong, session: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.vendor_subscribed, + erts.enif_make_int(env, session), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_vendor_at( + pid_long: jni.JLong, + session: c_int, + cmd: ?[*:0]const u8, + cmd_type: c_int, + args: ?[*:0]const u8, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.vendor_at, + erts.enif_make_int(env, session), + mobBtMakeVendorAtMap(env, cmd, cmd_type, args, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_started( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_started, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_stopped(pid_long: jni.JLong, session: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_stopped, + erts.enif_make_int(env, session), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_audio( + pid_long: jni.JLong, + session: c_int, + pcm: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_audio, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, pcm, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_error( + pid_long: jni.JLong, + session: c_int, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.err, + erts.enif_make_int(env, session), + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── SPP profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_spp_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, + name: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressName(env, address, name), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_data( + pid_long: jni.JLong, + session: c_int, + bytes: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.data, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, bytes, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_written(pid_long: jni.JLong, session: c_int, size: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.written, + erts.enif_make_int(env, session), + mobBtMakeSizeMap(env, size), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_error( + pid_long: jni.JLong, + session: c_int, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.err, + erts.enif_make_int(env, session), + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── HID profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_hid_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_input( + pid_long: jni.JLong, + session: c_int, + ev_type: c_int, + code: c_int, + value: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.input, + erts.enif_make_int(env, session), + mobBtMakeInputMap(env, ev_type, code, value), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_raw_report( + pid_long: jni.JLong, + session: c_int, + bytes: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.raw_report, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, bytes, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (7) NIF wrappers — `nif_bt_*` +// ═════════════════════════════════════════════════════════════════════════ +// +// Mirror VendorUsb structurally: pull caller pid via enif_self, attach +// JNIEnv, dispatch via the cached jmethodID, return :ok. All responses +// come back asynchronously through the mob_deliver_bt_* hooks. +// +// `vendor_at_send` and `_send_audio` / `_spp_write` are the only ones +// with non-trivial argument marshalling (byte arrays for the audio / +// SPP writes, two strings for vendor AT). +// +// `unsupported` short-circuit mirrors VendorUsb's pattern: if MobBridge +// doesn't have the matching @JvmStatic (old mob_new template), emit a +// single `{:bt, :error, nil, %{reason: :unsupported}}` and return :ok. + +fn btUnsupported(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + const msg_env = erts.enif_alloc_env() orelse return erts.ok(env); + defer erts.enif_free_env(msg_env); + const unsupported = erts.atom(msg_env, "unsupported"); + const keys = [_]erts.ERL_NIF_TERM{erts.atom(msg_env, "reason")}; + const vals = [_]erts.ERL_NIF_TERM{unsupported}; + const map = erts.makeMap(msg_env, &keys, &vals) orelse unsupported; + const msg = erts.makeTuple(msg_env, .{ + erts.atom(msg_env, "bt"), + erts.atom(msg_env, "error"), + erts.atom(msg_env, "nil"), + map, + }); + _ = erts.enif_send(null, &pid, msg_env, msg); + return erts.ok(env); +} + +// ── No-arg discovery / paired-list NIFs ── + +export fn nif_bt_list_paired( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_list_paired == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_list_paired, pidToJlong(pid)); + return erts.ok(env); +} + +export fn nif_bt_start_discovery( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_start_discovery == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_start_discovery, pidToJlong(pid)); + return erts.ok(env); +} + +export fn nif_bt_cancel_discovery( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_cancel_discovery == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_cancel_discovery, pidToJlong(pid)); + return erts.ok(env); +} + +// ── JSON-arg NIFs (pair / unpair / hfp_connect / spp_connect / hid_connect) ── + +export fn nif_bt_pair( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_pair == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_pair, pid, json); +} + +export fn nif_bt_unpair( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_unpair == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_unpair, pid, json); +} + +export fn nif_bt_hfp_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_hfp_connect, pid, json); +} + +export fn nif_bt_spp_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_spp_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_spp_connect, pid, json); +} + +export fn nif_bt_hid_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hid_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_hid_connect, pid, json); +} + +// ── Session-only NIFs ── + +export fn nif_bt_disconnect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_disconnect == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_disconnect, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hfp_subscribe_vendor_at( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_subscribe_vendor_at == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jjson = jni.newStringUTF(jenv, json); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_subscribe_vendor_at, + pidToJlong(pid), + @as(jni.JInt, session), + jjson, + ); + if (jjson != null) jni.deleteLocalRef(jenv, jjson); + return erts.ok(env); +} + +export fn nif_bt_hfp_start_sco( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_start_sco == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_start_sco, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hfp_stop_sco( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_stop_sco == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_stop_sco, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hid_subscribe_raw( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hid_subscribe_raw == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hid_subscribe_raw, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +// ── Two-string + session NIF (hfp_send_vendor_at/3) ── + +export fn nif_bt_hfp_send_vendor_at( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_send_vendor_at == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + const cmd_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const cmd = binToCString(cmd_bin) orelse return erts.atom(env, "error"); + defer freeCString(cmd); + + const args_bin = getBinOrIolist(env, argv[2]) orelse return erts.badarg(env); + const args = binToCString(args_bin) orelse return erts.atom(env, "error"); + defer freeCString(args); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jcmd = jni.newStringUTF(jenv, cmd); + const jargs = jni.newStringUTF(jenv, args); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_send_vendor_at, + pidToJlong(pid), + @as(jni.JInt, session), + jcmd, + jargs, + ); + if (jcmd != null) jni.deleteLocalRef(jenv, jcmd); + if (jargs != null) jni.deleteLocalRef(jenv, jargs); + return erts.ok(env); +} + +// ── Byte-array NIFs (hfp_send_audio/2, spp_write/2) — dirty IO ── + +export fn nif_bt_hfp_send_audio( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_send_audio == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_send_audio, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + +export fn nif_bt_spp_write( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_spp_write == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_spp_write, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + // ── nif_load: cache all method IDs at BEAM startup ─────────────────────── /// Required-method helper. Returns false if the method isn't on the @@ -3103,7 +4457,6 @@ inline fn cacheOptional(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const } fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) callconv(.c) c_int { - _ = env; _ = priv; _ = info; logi_nif("nif_load: entered, Bridge.cls={any}", .{Bridge.cls}); @@ -3194,6 +4547,34 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c cacheOptional(jenv, "vendor_usb_stop_reading", "(I)V", &Bridge.vendor_usb_stop_reading); cacheOptional(jenv, "vendor_usb_close", "(I)V", &Bridge.vendor_usb_close); + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + // Optional like VendorUsb — older mob_new templates without the matching + // Kotlin bt_* block will boot, with each bt_* NIF short-circuiting to + // {:bt, :error, nil, %{reason: :unsupported}}. + cacheOptional(jenv, "bt_list_paired", "(J)V", &Bridge.bt_list_paired); + cacheOptional(jenv, "bt_start_discovery", "(J)V", &Bridge.bt_start_discovery); + cacheOptional(jenv, "bt_cancel_discovery", "(J)V", &Bridge.bt_cancel_discovery); + cacheOptional(jenv, "bt_pair", "(JLjava/lang/String;)V", &Bridge.bt_pair); + cacheOptional(jenv, "bt_unpair", "(JLjava/lang/String;)V", &Bridge.bt_unpair); + cacheOptional(jenv, "bt_disconnect", "(JI)V", &Bridge.bt_disconnect); + cacheOptional(jenv, "bt_hfp_connect", "(JLjava/lang/String;)V", &Bridge.bt_hfp_connect); + cacheOptional(jenv, "bt_hfp_subscribe_vendor_at", "(JILjava/lang/String;)V", &Bridge.bt_hfp_subscribe_vendor_at); + cacheOptional(jenv, "bt_hfp_send_vendor_at", "(JILjava/lang/String;Ljava/lang/String;)V", &Bridge.bt_hfp_send_vendor_at); + cacheOptional(jenv, "bt_hfp_start_sco", "(JI)V", &Bridge.bt_hfp_start_sco); + cacheOptional(jenv, "bt_hfp_stop_sco", "(JI)V", &Bridge.bt_hfp_stop_sco); + cacheOptional(jenv, "bt_hfp_send_audio", "(JI[B)V", &Bridge.bt_hfp_send_audio); + cacheOptional(jenv, "bt_spp_connect", "(JLjava/lang/String;)V", &Bridge.bt_spp_connect); + cacheOptional(jenv, "bt_spp_write", "(JI[B)V", &Bridge.bt_spp_write); + cacheOptional(jenv, "bt_hid_connect", "(JLjava/lang/String;)V", &Bridge.bt_hid_connect); + cacheOptional(jenv, "bt_hid_subscribe_raw", "(JI)V", &Bridge.bt_hid_subscribe_raw); + + // BT atom cache + paired-list accumulator state. + mobBtAtomsInit(env); + if (mobBtPairedInit() != 0) { + loge_nif("nif_load: failed to create BT paired mutex", .{}); + return -1; + } + g_launch_notif_mutex = erts.enif_mutex_create("mob_launch_notif_mutex"); if (g_launch_notif_mutex == null) { loge_nif("nif_load: failed to create launch notif mutex", .{}); @@ -3311,6 +4692,23 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "vendor_usb_start_reading", .arity = 2, .fptr = nif_vendor_usb_start_reading, .flags = 0 }, .{ .name = "vendor_usb_stop_reading", .arity = 1, .fptr = nif_vendor_usb_stop_reading, .flags = 0 }, .{ .name = "vendor_usb_close", .arity = 1, .fptr = nif_vendor_usb_close, .flags = 0 }, + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + .{ .name = "bt_list_paired", .arity = 0, .fptr = nif_bt_list_paired, .flags = 0 }, + .{ .name = "bt_start_discovery", .arity = 0, .fptr = nif_bt_start_discovery, .flags = 0 }, + .{ .name = "bt_cancel_discovery", .arity = 0, .fptr = nif_bt_cancel_discovery, .flags = 0 }, + .{ .name = "bt_pair", .arity = 1, .fptr = nif_bt_pair, .flags = 0 }, + .{ .name = "bt_unpair", .arity = 1, .fptr = nif_bt_unpair, .flags = 0 }, + .{ .name = "bt_disconnect", .arity = 1, .fptr = nif_bt_disconnect, .flags = 0 }, + .{ .name = "bt_hfp_connect", .arity = 1, .fptr = nif_bt_hfp_connect, .flags = 0 }, + .{ .name = "bt_hfp_subscribe_vendor_at", .arity = 2, .fptr = nif_bt_hfp_subscribe_vendor_at, .flags = 0 }, + .{ .name = "bt_hfp_send_vendor_at", .arity = 3, .fptr = nif_bt_hfp_send_vendor_at, .flags = 0 }, + .{ .name = "bt_hfp_start_sco", .arity = 1, .fptr = nif_bt_hfp_start_sco, .flags = 0 }, + .{ .name = "bt_hfp_stop_sco", .arity = 1, .fptr = nif_bt_hfp_stop_sco, .flags = 0 }, + .{ .name = "bt_hfp_send_audio", .arity = 2, .fptr = nif_bt_hfp_send_audio, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "bt_spp_connect", .arity = 1, .fptr = nif_bt_spp_connect, .flags = 0 }, + .{ .name = "bt_spp_write", .arity = 2, .fptr = nif_bt_spp_write, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "bt_hid_connect", .arity = 1, .fptr = nif_bt_hid_connect, .flags = 0 }, + .{ .name = "bt_hid_subscribe_raw", .arity = 1, .fptr = nif_bt_hid_subscribe_raw, .flags = 0 }, }; var mob_nif_entry: erts.ErlNifEntry = .{ diff --git a/ios/mob_nif.m b/ios/mob_nif.m index c9a46c3d..422dc3e2 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5704,6 +5704,177 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF return enif_make_atom(env, "ok"); } +// ───────────────────────────────────────────────────────────────────────── +// Mob.Bt (Bluetooth Classic) — iOS unsupported stubs +// ───────────────────────────────────────────────────────────────────────── +// +// iOS exposes no public Bluetooth Classic API. (Bluetooth LE is available +// via CoreBluetooth, but Classic profiles like HFP/SPP/HID need MFi +// certification which Mob doesn't pursue.) All sixteen NIFs send +// {:bt, :error, nil, %{reason: :unsupported}} back to the caller and +// return :ok. Cross-platform screens see the error event and degrade +// gracefully via Mob.Peripheral.capabilities/0. + +static void send_bt_unsupported(ErlNifPid pid) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM reason_key = enif_make_atom(e, "reason"); + ERL_NIF_TERM reason_val = enif_make_atom(e, "unsupported"); + ERL_NIF_TERM map; + enif_make_map_put(e, enif_make_new_map(e), reason_key, reason_val, &map); + ERL_NIF_TERM msg = enif_make_tuple4(e, + enif_make_atom(e, "bt"), + enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), + map); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +static ERL_NIF_TERM nif_bt_list_paired(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_start_discovery(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_cancel_discovery(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_pair(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_unpair(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_disconnect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_subscribe_vendor_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_send_vendor_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_start_sco(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_stop_sco(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_send_audio(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_spp_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_spp_write(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hid_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hid_subscribe_raw(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + + // Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a @@ -5817,6 +5988,23 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, + // Bluetooth Classic (iOS = unsupported) + {"bt_list_paired", 0, nif_bt_list_paired, 0}, + {"bt_start_discovery", 0, nif_bt_start_discovery, 0}, + {"bt_cancel_discovery", 0, nif_bt_cancel_discovery, 0}, + {"bt_pair", 1, nif_bt_pair, 0}, + {"bt_unpair", 1, nif_bt_unpair, 0}, + {"bt_disconnect", 1, nif_bt_disconnect, 0}, + {"bt_hfp_connect", 1, nif_bt_hfp_connect, 0}, + {"bt_hfp_subscribe_vendor_at", 2, nif_bt_hfp_subscribe_vendor_at, 0}, + {"bt_hfp_send_vendor_at", 3, nif_bt_hfp_send_vendor_at, 0}, + {"bt_hfp_start_sco", 1, nif_bt_hfp_start_sco, 0}, + {"bt_hfp_stop_sco", 1, nif_bt_hfp_stop_sco, 0}, + {"bt_hfp_send_audio", 2, nif_bt_hfp_send_audio, 0}, + {"bt_spp_connect", 1, nif_bt_spp_connect, 0}, + {"bt_spp_write", 2, nif_bt_spp_write, 0}, + {"bt_hid_connect", 1, nif_bt_hid_connect, 0}, + {"bt_hid_subscribe_raw", 1, nif_bt_hid_subscribe_raw, 0}, // getaddrinfo can block on the resolver for seconds — dirty-IO so it // doesn't head-of-line-block the regular schedulers. See the impl // above for the iOS rationale. diff --git a/lib/mob/bt.ex b/lib/mob/bt.ex new file mode 100644 index 00000000..9ff853fa --- /dev/null +++ b/lib/mob/bt.ex @@ -0,0 +1,186 @@ +defmodule Mob.Bt do + @moduledoc """ + Bluetooth Classic (BR/EDR) — device-level discovery, pairing, and + cross-profile session management. + + Profile-specific operations live in submodules: + + * `Mob.Bt.Hfp` — Hands-Free Profile (audio + vendor AT commands). + Use this for headsets, PTT-equipped earpieces, etc. + * `Mob.Bt.Spp` — Serial Port Profile (RFCOMM byte streams). + Use this for legacy serial-over-Bluetooth devices (Arduino HC-05, + OBD-II readers, marine GPS, industrial sensors). + * `Mob.Bt.Hid` — Human Interface Device (input reports). + Use this for Bluetooth keyboards, mice, gamepads, finger PTTs. + + ## API style + + Same as the rest of Mob: callbacks return `socket` unchanged, results + arrive in `handle_info/2` as 4-tuples: + + {:bt, event_atom, session_id_or_nil, payload} + + Discovery / pairing events use `nil` for session_id; profile events + carry the session_id returned from the matching `connect/2`. + + ## Permissions + + Bluetooth requires runtime permissions on Android 12+ (API 31+): + + * `:bluetooth_scan` — for `start_discovery/1` + * `:bluetooth_connect` — for `pair/2`, `connect/*`, `disconnect/2` + + Request via `Mob.Permissions.request/2` before calling Mob.Bt functions. + + ## iOS + + Bluetooth Classic on iOS requires Apple's MFi (Made for iPhone) + certification — a paid, NDA-gated program. Mob.Bt is **Android-only**. + All functions return `{:error, :unsupported}` synchronously on iOS. + For iOS-equivalent custom-hardware connectivity, use `Mob.Ble`. + + ## Pairing flow + + Two pairing modes, auto-selected by whether `:pin` is given: + + # System UI flow — Android shows a system pairing dialog + socket = Mob.Bt.pair(socket, device) + + # Programmatic — PIN supplied via API, no UI + socket = Mob.Bt.pair(socket, device, pin: "0000") + + If the programmatic PIN fails or the device requires UI confirmation + (e.g. numeric comparison), Android falls back to the system UI + automatically. + + ## Disconnect + + One canonical disconnect for any profile session: + + Mob.Bt.disconnect(socket, session_id) + + The framework looks up which profile owns the session_id and routes + to the right profile-disconnect internally. Emits a profile-specific + event (`{:bt, :hfp_disconnected, ...}` etc). + """ + + @typedoc "An opaque session identifier for an active profile connection." + @type session_id :: pos_integer() + + @typedoc "A discovered or paired Bluetooth device." + @type device :: %{ + required(:address) => String.t(), + required(:name) => String.t(), + optional(:bond_state) => :none | :bonding | :bonded, + optional(:device_class) => non_neg_integer(), + optional(:uuids) => [String.t()] + } + + # ───────────────────────────────────────────────────────────── + # Public API + # ───────────────────────────────────────────────────────────── + + @doc """ + List currently paired (bonded) Bluetooth devices. + + Result arrives as `{:bt, :paired_devices, nil, [device]}`. + """ + @spec list_paired(socket :: term()) :: term() + def list_paired(socket) do + :mob_nif.bt_list_paired() + socket + end + + @doc """ + Begin Bluetooth Classic discovery. Discovered devices arrive as + individual `{:bt, :device_discovered, nil, device}` messages, terminated + by `{:bt, :discovery_finished, nil, nil}`. + + Discovery typically runs ~12 seconds on Android. + """ + @spec start_discovery(socket :: term()) :: term() + def start_discovery(socket) do + :mob_nif.bt_start_discovery() + socket + end + + @doc """ + Cancel an in-progress discovery. + """ + @spec cancel_discovery(socket :: term()) :: term() + def cancel_discovery(socket) do + :mob_nif.bt_cancel_discovery() + socket + end + + @doc """ + Pair (bond) with a Bluetooth device. + + Without `:pin`, Android shows the system pairing dialog (user enters + PIN). With `:pin`, attempts programmatic pairing using the supplied + PIN; falls back to system UI if the device demands user confirmation. + + Result arrives as one of: + + * `{:bt, :pair_succeeded, nil, device}` + * `{:bt, :pair_failed, nil, %{device: device, reason: atom()}}` + """ + @spec pair(socket :: term(), device(), keyword()) :: term() + def pair(socket, device, opts \\ []) do + pin = Keyword.get(opts, :pin) + json = encode_pair(device, pin) + :mob_nif.bt_pair(json) + socket + end + + @doc """ + Remove an existing pairing (bond). + + Result: `{:bt, :unpaired, nil, device}`. + """ + @spec unpair(socket :: term(), device()) :: term() + def unpair(socket, device) do + json = encode_device(device) + :mob_nif.bt_unpair(json) + socket + end + + @doc """ + Disconnect a profile session by `session_id`. + + Works for any profile (`Mob.Bt.Hfp`, `Mob.Bt.Spp`, `Mob.Bt.Hid`) — the + framework dispatches internally based on which profile owns the session. + + Emits a profile-specific disconnect event: + + * `{:bt, :hfp_disconnected, session_id, reason}` + * `{:bt, :spp_disconnected, session_id, reason}` + * `{:bt, :hid_disconnected, session_id, reason}` + """ + @spec disconnect(socket :: term(), session_id()) :: term() + def disconnect(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_disconnect(session_id) + socket + end + + # Internal — JSON helpers (nil-safe per the VendorUsb playbook) + # ───────────────────────────────────────────────────────────── + + defp encode_pair(device, nil), do: encode_device(device) + + defp encode_pair(device, pin) when is_binary(pin) do + device |> Map.put(:pin, pin) |> encode_device() + end + + defp encode_device(device) do + device + |> Map.new() + |> drop_nil_values() + |> :json.encode() + |> IO.iodata_to_binary() + end + + defp drop_nil_values(map) do + :maps.filter(fn _k, v -> v != nil end, map) + end +end diff --git a/lib/mob/bt/hfp.ex b/lib/mob/bt/hfp.ex new file mode 100644 index 00000000..8e859a78 --- /dev/null +++ b/lib/mob/bt/hfp.ex @@ -0,0 +1,179 @@ +defmodule Mob.Bt.Hfp do + @moduledoc """ + Bluetooth Classic Hands-Free Profile (HFP) — audio + vendor AT commands. + + Use this for headsets, PTT-equipped earpieces (Hytera EHW02, etc), and + any device that exposes an HFP control link plus an SCO audio link. + + See `Mob.Bt` for pairing, discovery, and disconnect — those are + device-level concerns. Profile-specific operations live here. + + ## Typical flow + + # 1. Pair (only needed once per device — Mob.Bt.pair/2) + socket = Mob.Bt.pair(socket, device) + # {:bt, :pair_succeeded, nil, device} + + # 2. Connect HFP profile + socket = Mob.Bt.Hfp.connect(socket, device) + # {:bt, :hfp_connected, session_id, device} + + # 3. (Optional) subscribe to vendor AT commands the headset emits. + # Hytera EHW02 fires +CTXD on PTT press, +CUTXC on release. + socket = Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id) + # {:bt, :vendor_at, session_id, %{cmd: "+CTXD", args: ""}} + + # 4. (Optional) bring up the SCO audio link. + socket = Mob.Bt.Hfp.start_sco(socket, session_id) + # {:bt, :sco_started, session_id, %{sample_rate: 8000, ...}} + # then audio chunks stream as: + # {:bt, :sco_audio_in, session_id, pcm_bytes} + + # 5. Send PCM audio out to the headset earpiece: + Mob.Bt.Hfp.send_audio(socket, session_id, pcm_bytes) + + # 6. Disconnect (one canonical path — Mob.Bt.disconnect/2) + Mob.Bt.disconnect(socket, session_id) + + ## Vendor AT commands + + HFP defines a small core AT vocabulary (call control, volume, ring). + Headset vendors extend with their own `+`-prefixed commands. Subscribing + via `subscribe_vendor_at/2` delivers any *unrecognized* AT command from + the headset as `{:bt, :vendor_at, session_id, %{cmd, args}}` for your + app to interpret. + + Sending a vendor AT command to the headset is `send_vendor_at/4`. + Standard responses (`OK`, `ERROR`) are emitted by Android automatically — + use the `response` argument to override only when the AT spec demands + custom payload. + + ## SCO audio + + SCO (Synchronous Connection-Oriented) is the real-time bidirectional + voice channel HFP uses for call audio. `start_sco/2` opens it; PCM + bytes flow both ways until `stop_sco/2` or disconnect. + + Format is 8 kHz / 16-bit / mono PCM by default; modern devices may + negotiate up to 16 kHz wideband (mSBC). The `:sco_started` event + reports the negotiated parameters. + """ + + alias Mob.Bt + + @doc """ + Open an HFP profile connection to `device`. The device must already + be paired (`Mob.Bt.pair/2`). + + Result: `{:bt, :hfp_connected, session_id, device}` on success, + `{:bt, :hfp_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device()) :: term() + def connect(socket, device) do + json = encode_device(device) + :mob_nif.bt_hfp_connect(json) + socket + end + + @doc """ + Subscribe to vendor-specific AT commands emitted by the headset on the + given HFP session. + + The caller specifies which BT SIG company IDs to listen for via the + `:company_ids` option. Android's ACTION_VENDOR_SPECIFIC_HEADSET_EVENT + broadcasts are only delivered for explicitly-registered IDs, so a + default empty list means no events will be received. + + Common values: + + * `313` — Hytera (PTT commercial radios) + * `76` — Apple (AirPods custom events) + * `10` — Qualcomm + * `1117` — Plantronics / Poly + + Standard (non-vendor) AT commands are handled by Android's HFP stack + and never surface here. + + Stream events: `{:bt, :vendor_at, session_id, %{cmd: String.t(), cmd_type: integer(), args: String.t(), address: String.t()}}`. + + ## Example + + Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id, company_ids: [313]) + """ + @spec subscribe_vendor_at(socket :: term(), Bt.session_id(), keyword()) :: term() + def subscribe_vendor_at(socket, session_id, opts \\ []) + when is_integer(session_id) and is_list(opts) do + company_ids = Keyword.get(opts, :company_ids, []) + json = Jason.encode!(%{company_ids: company_ids}) + :mob_nif.bt_hfp_subscribe_vendor_at(session_id, json) + socket + end + + @doc """ + Send a vendor AT command to the headset. Useful for headset-specific + feature toggles or query/response protocols. + + Mob.Bt.Hfp.send_vendor_at(socket, session, "+XAPL", "0505,2") + """ + @spec send_vendor_at(socket :: term(), Bt.session_id(), String.t(), String.t()) :: term() + def send_vendor_at(socket, session_id, cmd, args \\ "") + when is_integer(session_id) and is_binary(cmd) and is_binary(args) do + :mob_nif.bt_hfp_send_vendor_at(session_id, cmd, args) + socket + end + + @doc """ + Open the SCO audio link for this HFP session. + + Emits `{:bt, :sco_started, session_id, %{sample_rate: integer, encoding: atom, channels: integer}}` + when the link is up. Mic audio then streams as + `{:bt, :sco_audio_in, session_id, pcm_bytes}`. + + On failure: `{:bt, :sco_failed, session_id, reason}`. + """ + @spec start_sco(socket :: term(), Bt.session_id()) :: term() + def start_sco(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hfp_start_sco(session_id) + socket + end + + @doc """ + Close the SCO audio link without disconnecting the HFP session. + + Emits `{:bt, :sco_stopped, session_id, nil}`. + """ + @spec stop_sco(socket :: term(), Bt.session_id()) :: term() + def stop_sco(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hfp_stop_sco(session_id) + socket + end + + @doc """ + Send PCM audio bytes out the SCO link to the headset earpiece. + + Bytes are linear PCM matching the format reported in `:sco_started` + (typically 8 kHz / 16-bit / mono signed little-endian). + + Returns the socket. This is fire-and-forget; no completion event. + """ + @spec send_audio(socket :: term(), Bt.session_id(), binary()) :: term() + def send_audio(socket, session_id, pcm_bytes) + when is_integer(session_id) and is_binary(pcm_bytes) do + :mob_nif.bt_hfp_send_audio(session_id, pcm_bytes) + socket + end + + @doc false + # ───────────────────────────────────────────────────────────── + # JSON helpers + # ───────────────────────────────────────────────────────────── + + defp encode_device(device) do + device + |> Map.new() + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + end +end diff --git a/lib/mob/bt/hid.ex b/lib/mob/bt/hid.ex new file mode 100644 index 00000000..31516676 --- /dev/null +++ b/lib/mob/bt/hid.ex @@ -0,0 +1,99 @@ +defmodule Mob.Bt.Hid do + @moduledoc """ + Bluetooth Classic Human Interface Device (HID) — input listener. + + Use this for Bluetooth keyboards, mice, gamepads, finger PTTs, scanners, + presenter remotes, and any device that emits HID input reports + (button/key/axis events) over Bluetooth. + + See `Mob.Bt` for pairing, discovery, and disconnect. + + ## Scope + + This module is **read-only** by design. HID hosts (phones) almost never + send output reports to peripherals — that's a force-feedback / + rumble-pack edge case. If your hardware genuinely needs output reports, + open an issue. + + ## Typical flow + + # 1. Pair (Mob.Bt.pair/2) + + # 2. Connect HID profile. + socket = Mob.Bt.Hid.connect(socket, device) + # {:bt, :hid_connected, session_id, device} + + # 3. Input reports stream: + # {:bt, :hid_input, session_id, + # %{usage_page: 0x07, usage: 0x29, value: 1}} + # (HID Keyboard/Keypad, key 0x29 = Escape, pressed) + + # 4. Disconnect (Mob.Bt.disconnect/2) + + ## Input report shape + + Reports are decoded by the Android HID stack into usage-page + + usage + value triples per the HID Usage Tables spec. Common pages: + + * `0x01` — Generic Desktop (mouse/joystick X/Y, wheel, etc.) + * `0x07` — Keyboard/Keypad + * `0x09` — Button (gamepad face buttons) + * `0x0C` — Consumer (volume, play, mute, custom) + * `0xFF00`–`0xFFFF` — Vendor-defined + + Multi-axis events arrive as separate messages, one per axis. Synthesize + combined input on the receive side if needed. + + ## Receiving raw reports + + If the device's HID descriptor is non-standard or the high-level + `:hid_input` shape isn't sufficient, subscribe to raw reports with + `subscribe_raw/2` and parse the bytes yourself. + + Stream: `{:bt, :hid_raw_report, session_id, %{report_id: integer, bytes: binary}}`. + """ + + alias Mob.Bt + + @doc """ + Open an HID profile connection to `device`. The device must already be + paired. + + Result: `{:bt, :hid_connected, session_id, device}` on success, + `{:bt, :hid_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device()) :: term() + def connect(socket, device) do + json = encode_device(device) + :mob_nif.bt_hid_connect(json) + socket + end + + @doc """ + Subscribe to raw HID input reports (bypasses Android's parser). + + Use only when the device's HID descriptor is non-standard or the + high-level `:hid_input` events miss data you need. + + Stream: `{:bt, :hid_raw_report, session_id, %{report_id, bytes}}`. + """ + @spec subscribe_raw(socket :: term(), Bt.session_id()) :: term() + def subscribe_raw(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hid_subscribe_raw(session_id) + socket + end + + @doc false + # ───────────────────────────────────────────────────────────── + # JSON helpers + # ───────────────────────────────────────────────────────────── + + defp encode_device(device) do + device + |> Map.new() + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + end +end diff --git a/lib/mob/bt/spp.ex b/lib/mob/bt/spp.ex new file mode 100644 index 00000000..a9d0aa9e --- /dev/null +++ b/lib/mob/bt/spp.ex @@ -0,0 +1,95 @@ +defmodule Mob.Bt.Spp do + @moduledoc """ + Bluetooth Classic Serial Port Profile (SPP) — RFCOMM byte streams. + + Use this for legacy serial-over-Bluetooth devices: Arduino HC-05/HC-06 + modules, OBD-II ELM327 readers, marine GPS pucks, industrial sensors, + legacy barcode scanners, etc. Anything that exposes itself as a + bidirectional byte pipe over a custom RFCOMM channel UUID. + + See `Mob.Bt` for pairing, discovery, and disconnect. + + ## Typical flow + + # 1. Pair (Mob.Bt.pair/2) + + # 2. Connect SPP, supplying the RFCOMM service UUID. + # The well-known SPP UUID is "00001101-0000-1000-8000-00805F9B34FB". + socket = Mob.Bt.Spp.connect(socket, device, + uuid: "00001101-0000-1000-8000-00805F9B34FB") + # {:bt, :spp_connected, session_id, device} + + # 3. Receive bytes: + # {:bt, :spp_data, session_id, bytes} + + # 4. Send bytes: + Mob.Bt.Spp.write(socket, session_id, "ATZ\\r\\n") + + # 5. Disconnect (Mob.Bt.disconnect/2) + + ## UUIDs + + Most SPP devices advertise the standard SPP UUID + `00001101-0000-1000-8000-00805F9B34FB`. Some manufacturers use custom + UUIDs to scope to a specific protocol on the same physical device. + Pass via the `:uuid` opt; if omitted, the standard SPP UUID is used. + + ## Insecure RFCOMM + + By default the connection uses the secure RFCOMM channel (encrypted, + requires bond). Some legacy devices (especially HC-06 clones) only + accept insecure RFCOMM. Pass `secure: false` to fall back. + """ + + alias Mob.Bt + + @standard_spp_uuid "00001101-0000-1000-8000-00805F9B34FB" + + @doc """ + Open an SPP (RFCOMM) connection to `device`. + + ## Options + + * `:uuid` — RFCOMM service UUID (default: `"#{@standard_spp_uuid}"`) + * `:secure` — `true` (default, encrypted) or `false` (legacy insecure) + + Result: `{:bt, :spp_connected, session_id, device}` on success, + `{:bt, :spp_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device(), keyword()) :: term() + def connect(socket, device, opts \\ []) do + uuid = Keyword.get(opts, :uuid, @standard_spp_uuid) + secure = Keyword.get(opts, :secure, true) + + json = + device + |> Map.new() + |> Map.put(:uuid, uuid) + |> Map.put(:secure, secure) + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + + :mob_nif.bt_spp_connect(json) + socket + end + + @doc """ + Write a byte payload to the SPP session. + + Returns the socket. Fire-and-forget: bytes are queued in Kotlin's + output stream and flushed asynchronously. No completion event. + + Errors during write are surfaced as + `{:bt, :spp_disconnected, session_id, reason}` (Kotlin closes the + socket on write failure). + """ + @spec write(socket :: term(), Bt.session_id(), binary()) :: term() + def write(socket, session_id, bytes) + when is_integer(session_id) and is_binary(bytes) do + :mob_nif.bt_spp_write(session_id, bytes) + socket + end + +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 722932d2..b1644ede 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -104,6 +104,23 @@ vendor_usb_start_reading/2, vendor_usb_stop_reading/1, vendor_usb_close/1, + %% Bluetooth Classic (Android; iOS returns :unsupported) + 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, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, %% DNS — see Mob.DNS and guides/dns_on_ios.md resolve_ipv4/1 ]). @@ -196,6 +213,23 @@ vendor_usb_start_reading/2, vendor_usb_stop_reading/1, vendor_usb_close/1, + %% Bluetooth Classic + 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, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, %% DNS — in-process getaddrinfo so iOS apps bypass BEAM's %% broken inet_gethost path. See `Mob.DNS` for the Elixir %% wrapper and `guides/dns_on_ios.md` for the why. @@ -289,4 +323,21 @@ vendor_usb_bulk_write(_Session, _Bytes, _TimeoutMs) -> erlang:nif_error(not_load vendor_usb_start_reading(_Session, _ChunkBytes) -> erlang:nif_error(not_loaded). vendor_usb_stop_reading(_Session) -> erlang:nif_error(not_loaded). vendor_usb_close(_Session) -> erlang:nif_error(not_loaded). +%% Bluetooth Classic +bt_list_paired() -> erlang:nif_error(not_loaded). +bt_start_discovery() -> erlang:nif_error(not_loaded). +bt_cancel_discovery() -> erlang:nif_error(not_loaded). +bt_pair(_DeviceAndPinJson) -> erlang:nif_error(not_loaded). +bt_unpair(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_disconnect(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hfp_subscribe_vendor_at(_Session, _CompanyIdsJson) -> erlang:nif_error(not_loaded). +bt_hfp_send_vendor_at(_Session, _Cmd, _Args) -> erlang:nif_error(not_loaded). +bt_hfp_start_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_stop_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_send_audio(_Session, _Pcm) -> erlang:nif_error(not_loaded). +bt_spp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_spp_write(_Session, _Bytes) -> erlang:nif_error(not_loaded). +bt_hid_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hid_subscribe_raw(_Session) -> erlang:nif_error(not_loaded). resolve_ipv4(_Host) -> erlang:nif_error(not_loaded). From 6e1e122f485cde616831bf896e79cfeaced03d09 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 12:35:16 -0600 Subject: [PATCH 065/254] 0.6.2 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 862c6a84..7431defc 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.1", + version: "0.6.2", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 928a8dc541bd99dcdb087929b80daa499cb7669f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 14:16:03 -0600 Subject: [PATCH 066/254] camera: rotate session to portrait so YOLO sees an upright scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ios/MobRootView.swift | 27 ++++++++++++++++++++++++++- ios/mob_nif.m | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 9191ac26..1f275098 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -829,11 +829,26 @@ private struct MobCameraPreviewView: UIViewRepresentable { view.cameraLayer.videoGravity = .resizeAspectFill // Connect immediately if the session is already running. view.cameraLayer.session = g_preview_session + rotatePreviewConnection(view: view) // Observe future session changes (start, stop, facing swap). context.coordinator.startObserving(view: view) return view } + // Pin the preview to portrait so what the user sees matches the + // upright frame we ship to the model. Without this, the sensor's + // landscape-native output renders sideways in a portrait UI. + private func rotatePreviewConnection(view: CameraPreviewUIView) { + guard let conn = view.cameraLayer.connection else { return } + if #available(iOS 17.0, *) { + if conn.isVideoRotationAngleSupported(90) { + conn.videoRotationAngle = 90 + } + } else if conn.isVideoOrientationSupported { + conn.videoOrientation = .portrait + } + } + func updateUIView(_ view: CameraPreviewUIView, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator() } @@ -849,7 +864,17 @@ private struct MobCameraPreviewView: UIViewRepresentable { object: nil, queue: .main ) { [weak self] _ in - self?.hostView?.cameraLayer.session = g_preview_session + guard let view = self?.hostView else { return } + view.cameraLayer.session = g_preview_session + if let conn = view.cameraLayer.connection { + if #available(iOS 17.0, *) { + if conn.isVideoRotationAngleSupported(90) { + conn.videoRotationAngle = 90 + } + } else if conn.isVideoOrientationSupported { + conn.videoOrientation = .portrait + } + } } } diff --git a/ios/mob_nif.m b/ios/mob_nif.m index e1df8bf0..82dcb6e5 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2806,6 +2806,25 @@ static ERL_NIF_TERM nif_camera_start_frame_stream(ErlNifEnv *env, int argc, } [g_preview_session commitConfiguration]; + // Rotate the connection to portrait so the model sees upright + // pixels. The sensor's native orientation is landscape-right; + // without this, YOLO sees a 90°-rotated scene and misclassifies + // everything (a jar becomes a horizontal bar that looks like + // "laptop"). 90° rotation maps landscape sensor → portrait + // upright. videoRotationAngle is iOS 17+; older builds get the + // deprecated videoOrientation as a fallback. + AVCaptureConnection *conn = [output connectionWithMediaType:AVMediaTypeVideo]; + if (conn) { + if (@available(iOS 17.0, *)) { + if ([conn isVideoRotationAngleSupported:90.0]) + conn.videoRotationAngle = 90.0; + } else { + if ([conn isVideoOrientationSupported]) + conn.videoOrientation = AVCaptureVideoOrientationPortrait; + } + NSLog(@"[mob/camera] frame output rotated to portrait"); + } + if (!g_preview_session.isRunning) { [g_preview_session startRunning]; NSLog(@"[mob/camera] session startRunning (from frame_stream)"); From ba82c87b2c24a02b72be6ed1b8ea080bcd06805c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 14:47:11 -0600 Subject: [PATCH 067/254] 0.6.3 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 7431defc..a7ffbe5d 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.2", + version: "0.6.3", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 3398c4e0d3d13b4110052eab947cd250724fa53e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 17:51:37 -0600 Subject: [PATCH 068/254] Mob.Bt review fixes: drop Jason runtime dep, dedupe encoders, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/jni/mob_beam.h | 1 - ios/mob_nif.m | 11 +++---- lib/mob/bt.ex | 14 ++++++--- lib/mob/bt/hfp.ex | 28 ++++++++--------- lib/mob/bt/hid.ex | 15 +--------- lib/mob/bt/spp.ex | 24 +++++++-------- test/mob/bt/hfp_test.exs | 29 ++++++++++++++++++ test/mob/bt/spp_test.exs | 36 ++++++++++++++++++++++ test/mob/bt_test.exs | 65 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 169 insertions(+), 54 deletions(-) create mode 100644 test/mob/bt/hfp_test.exs create mode 100644 test/mob/bt/spp_test.exs create mode 100644 test/mob/bt_test.exs diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 4eb79a94..48d8d756 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -140,7 +140,6 @@ void mob_send_component_event(int handle, const char *event, const char *payload // `scheme` must be "light" or "dark". void mob_send_color_scheme_changed(const char *scheme); - // mob_beam.h additions for Mob.Bt // // Append these to the existing mob_beam.h, after the diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 422dc3e2..c98abc2d 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5721,11 +5721,8 @@ static void send_bt_unsupported(ErlNifPid pid) { ERL_NIF_TERM reason_val = enif_make_atom(e, "unsupported"); ERL_NIF_TERM map; enif_make_map_put(e, enif_make_new_map(e), reason_key, reason_val, &map); - ERL_NIF_TERM msg = enif_make_tuple4(e, - enif_make_atom(e, "bt"), - enif_make_atom(e, "error"), - enif_make_atom(e, "nil"), - map); + ERL_NIF_TERM msg = enif_make_tuple4(e, enif_make_atom(e, "bt"), enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), map); enif_send(NULL, &pid, e, msg); enif_free_env(e); } @@ -5793,7 +5790,8 @@ static ERL_NIF_TERM nif_bt_hfp_connect(ErlNifEnv *env, int argc, const ERL_NIF_T return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_bt_hfp_subscribe_vendor_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_bt_hfp_subscribe_vendor_at(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { (void)argc; (void)argv; ErlNifPid pid; @@ -5874,7 +5872,6 @@ static ERL_NIF_TERM nif_bt_hid_subscribe_raw(ErlNifEnv *env, int argc, const ERL return enif_make_atom(env, "ok"); } - // Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a diff --git a/lib/mob/bt.ex b/lib/mob/bt.ex index 9ff853fa..51ec39aa 100644 --- a/lib/mob/bt.ex +++ b/lib/mob/bt.ex @@ -163,16 +163,22 @@ defmodule Mob.Bt do socket end - # Internal — JSON helpers (nil-safe per the VendorUsb playbook) + # Internal JSON helpers, exposed `@doc false` so the test suite can + # exercise the encoded shape directly (the public functions all dead-end + # in a NIF call). Nil-safe per the VendorUsb playbook. # ───────────────────────────────────────────────────────────── - defp encode_pair(device, nil), do: encode_device(device) + @doc false + @spec encode_pair(device(), String.t() | nil) :: binary() + def encode_pair(device, nil), do: encode_device(device) - defp encode_pair(device, pin) when is_binary(pin) do + def encode_pair(device, pin) when is_binary(pin) do device |> Map.put(:pin, pin) |> encode_device() end - defp encode_device(device) do + @doc false + @spec encode_device(map()) :: binary() + def encode_device(device) do device |> Map.new() |> drop_nil_values() diff --git a/lib/mob/bt/hfp.ex b/lib/mob/bt/hfp.ex index 8e859a78..f3bbd17d 100644 --- a/lib/mob/bt/hfp.ex +++ b/lib/mob/bt/hfp.ex @@ -71,7 +71,7 @@ defmodule Mob.Bt.Hfp do """ @spec connect(socket :: term(), Bt.device()) :: term() def connect(socket, device) do - json = encode_device(device) + json = Bt.encode_device(device) :mob_nif.bt_hfp_connect(json) socket end @@ -104,12 +104,21 @@ defmodule Mob.Bt.Hfp do @spec subscribe_vendor_at(socket :: term(), Bt.session_id(), keyword()) :: term() def subscribe_vendor_at(socket, session_id, opts \\ []) when is_integer(session_id) and is_list(opts) do - company_ids = Keyword.get(opts, :company_ids, []) - json = Jason.encode!(%{company_ids: company_ids}) + json = encode_vendor_at_opts(opts) :mob_nif.bt_hfp_subscribe_vendor_at(session_id, json) socket end + @doc false + @spec encode_vendor_at_opts(keyword()) :: binary() + def encode_vendor_at_opts(opts) when is_list(opts) do + company_ids = Keyword.get(opts, :company_ids, []) + + %{company_ids: company_ids} + |> :json.encode() + |> IO.iodata_to_binary() + end + @doc """ Send a vendor AT command to the headset. Useful for headset-specific feature toggles or query/response protocols. @@ -163,17 +172,4 @@ defmodule Mob.Bt.Hfp do :mob_nif.bt_hfp_send_audio(session_id, pcm_bytes) socket end - - @doc false - # ───────────────────────────────────────────────────────────── - # JSON helpers - # ───────────────────────────────────────────────────────────── - - defp encode_device(device) do - device - |> Map.new() - |> Map.reject(fn {_k, v} -> is_nil(v) end) - |> :json.encode() - |> IO.iodata_to_binary() - end end diff --git a/lib/mob/bt/hid.ex b/lib/mob/bt/hid.ex index 31516676..7c793905 100644 --- a/lib/mob/bt/hid.ex +++ b/lib/mob/bt/hid.ex @@ -65,7 +65,7 @@ defmodule Mob.Bt.Hid do """ @spec connect(socket :: term(), Bt.device()) :: term() def connect(socket, device) do - json = encode_device(device) + json = Bt.encode_device(device) :mob_nif.bt_hid_connect(json) socket end @@ -83,17 +83,4 @@ defmodule Mob.Bt.Hid do :mob_nif.bt_hid_subscribe_raw(session_id) socket end - - @doc false - # ───────────────────────────────────────────────────────────── - # JSON helpers - # ───────────────────────────────────────────────────────────── - - defp encode_device(device) do - device - |> Map.new() - |> Map.reject(fn {_k, v} -> is_nil(v) end) - |> :json.encode() - |> IO.iodata_to_binary() - end end diff --git a/lib/mob/bt/spp.ex b/lib/mob/bt/spp.ex index a9d0aa9e..e2fa8be9 100644 --- a/lib/mob/bt/spp.ex +++ b/lib/mob/bt/spp.ex @@ -59,20 +59,21 @@ defmodule Mob.Bt.Spp do """ @spec connect(socket :: term(), Bt.device(), keyword()) :: term() def connect(socket, device, opts \\ []) do + json = encode_connect(device, opts) + :mob_nif.bt_spp_connect(json) + socket + end + + @doc false + @spec encode_connect(Bt.device(), keyword()) :: binary() + def encode_connect(device, opts) when is_list(opts) do uuid = Keyword.get(opts, :uuid, @standard_spp_uuid) secure = Keyword.get(opts, :secure, true) - json = - device - |> Map.new() - |> Map.put(:uuid, uuid) - |> Map.put(:secure, secure) - |> Map.reject(fn {_k, v} -> is_nil(v) end) - |> :json.encode() - |> IO.iodata_to_binary() - - :mob_nif.bt_spp_connect(json) - socket + device + |> Map.put(:uuid, uuid) + |> Map.put(:secure, secure) + |> Bt.encode_device() end @doc """ @@ -91,5 +92,4 @@ defmodule Mob.Bt.Spp do :mob_nif.bt_spp_write(session_id, bytes) socket end - end diff --git a/test/mob/bt/hfp_test.exs b/test/mob/bt/hfp_test.exs new file mode 100644 index 00000000..358626f8 --- /dev/null +++ b/test/mob/bt/hfp_test.exs @@ -0,0 +1,29 @@ +defmodule Mob.Bt.HfpTest do + use ExUnit.Case, async: true + + alias Mob.Bt.Hfp + + describe "encode_vendor_at_opts/1" do + test "default — empty company_ids list" do + assert :json.decode(Hfp.encode_vendor_at_opts([])) == %{"company_ids" => []} + end + + test "passes through a single company id" do + assert :json.decode(Hfp.encode_vendor_at_opts(company_ids: [313])) == %{ + "company_ids" => [313] + } + end + + test "passes through several company ids in order" do + assert :json.decode(Hfp.encode_vendor_at_opts(company_ids: [313, 76, 10])) == %{ + "company_ids" => [313, 76, 10] + } + end + + test "ignores unknown opts (forward-compatible)" do + assert :json.decode(Hfp.encode_vendor_at_opts(company_ids: [313], extra: :ignored)) == %{ + "company_ids" => [313] + } + end + end +end diff --git a/test/mob/bt/spp_test.exs b/test/mob/bt/spp_test.exs new file mode 100644 index 00000000..49a992ef --- /dev/null +++ b/test/mob/bt/spp_test.exs @@ -0,0 +1,36 @@ +defmodule Mob.Bt.SppTest do + use ExUnit.Case, async: true + + alias Mob.Bt.Spp + + @device %{address: "AA:BB:CC:DD:EE:FF", name: "Sensor"} + @standard_spp_uuid "00001101-0000-1000-8000-00805F9B34FB" + + describe "encode_connect/2" do + test "defaults to the standard SPP UUID + secure RFCOMM" do + decoded = :json.decode(Spp.encode_connect(@device, [])) + assert decoded["uuid"] == @standard_spp_uuid + assert decoded["secure"] == true + assert decoded["address"] == "AA:BB:CC:DD:EE:FF" + end + + test "honors a custom :uuid" do + custom = "12345678-1234-1234-1234-123456789012" + decoded = :json.decode(Spp.encode_connect(@device, uuid: custom)) + assert decoded["uuid"] == custom + end + + test "honors secure: false (insecure RFCOMM for legacy devices)" do + decoded = :json.decode(Spp.encode_connect(@device, secure: false)) + assert decoded["secure"] == false + end + + test "drops nil-valued device fields but keeps explicit secure: false" do + device = %{address: "AA:BB", name: "X", uuids: nil, bond_state: nil} + decoded = :json.decode(Spp.encode_connect(device, secure: false)) + refute Map.has_key?(decoded, "uuids") + refute Map.has_key?(decoded, "bond_state") + assert decoded["secure"] == false + end + end +end diff --git a/test/mob/bt_test.exs b/test/mob/bt_test.exs new file mode 100644 index 00000000..b4d99b4e --- /dev/null +++ b/test/mob/bt_test.exs @@ -0,0 +1,65 @@ +defmodule Mob.BtTest do + use ExUnit.Case, async: true + + alias Mob.Bt + + # The public surface of Mob.Bt + sub-profiles dead-ends in :mob_nif + # calls, so the unit-testable layer is the JSON the Elixir side + # hands to the NIF. The encoders are exposed `@doc false` for + # exactly that reason — tested here, never advertised to users. + + describe "encode_device/1" do + test "round-trips a minimal device map" do + device = %{address: "AA:BB:CC:DD:EE:FF", name: "TestDev"} + + assert decoded(Bt.encode_device(device)) == %{ + "address" => "AA:BB:CC:DD:EE:FF", + "name" => "TestDev" + } + end + + test "drops keys whose value is nil" do + device = %{address: "AA:BB:CC:DD:EE:FF", name: "TestDev", bond_state: nil, uuids: nil} + decoded = decoded(Bt.encode_device(device)) + assert Map.keys(decoded) |> Enum.sort() == ["address", "name"] + end + + test "preserves a populated optional field" do + device = %{ + address: "AA:BB:CC:DD:EE:FF", + name: "TestDev", + bond_state: :bonded, + device_class: 1024, + uuids: ["00001101-0000-1000-8000-00805F9B34FB"] + } + + decoded = decoded(Bt.encode_device(device)) + assert decoded["bond_state"] == "bonded" + assert decoded["device_class"] == 1024 + assert decoded["uuids"] == ["00001101-0000-1000-8000-00805F9B34FB"] + end + + test "accepts a Keyword list and normalises it to a map" do + assert decoded(Bt.encode_device(address: "AA:BB", name: "X")) == %{ + "address" => "AA:BB", + "name" => "X" + } + end + end + + describe "encode_pair/2" do + test "without a PIN, output matches encode_device/1 byte-for-byte" do + device = %{address: "AA:BB:CC:DD:EE:FF", name: "TestDev"} + assert Bt.encode_pair(device, nil) == Bt.encode_device(device) + end + + test "with a PIN, embeds it in the encoded payload" do + device = %{address: "AA:BB:CC:DD:EE:FF", name: "TestDev"} + decoded = decoded(Bt.encode_pair(device, "0000")) + assert decoded["pin"] == "0000" + assert decoded["address"] == "AA:BB:CC:DD:EE:FF" + end + end + + defp decoded(json) when is_binary(json), do: :json.decode(json) +end From 9c93d249f6925e6e2c62e89e18b1c0a58378222b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 20:16:32 -0600 Subject: [PATCH 069/254] 0.6.4 --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index a7ffbe5d..488067bb 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.3", + version: "0.6.4", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From c0eefbf4e98c1cd159d6137eb5a674e1687ef545 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 14 May 2026 13:02:41 -0600 Subject: [PATCH 070/254] Mob.GpuView: Metal fragment-shader surface (iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new component for GPU shader rendering driven from BEAM state. The native side hosts an MTKView, compiles a user-supplied Metal Shading Language fragment shader, binds per-frame uniforms in declaration order at fragment buffer slot 0, and renders a full-screen quad at the display refresh rate. The host emits a built-in vertex shader that emits a full-screen quad with a (0..1, 0..1) uv. The user-supplied shader receives that as `VertexOut` and exports `fragment_main`. No metal_stdlib include or VertexOut redeclaration is needed in the shader — the host prepends both. iOS-only in v1. Android (`GLSurfaceView` + GLES 3.0) is a follow-up; the API is structured to accept a `%{ios: "...MSL...", android: "...GLSL ES..."}` map form so the same component will work cross-platform once the Android side lands. ## Why uniforms are an ordered list, not a map Elixir map iteration order is not stable across runtimes or sizes — empirically confirmed against the Mandelbrot demo on iPhone, where `%{center: ..., zoom: ..., max_iter: ...}` iterated as `[:zoom, :max_iter, :center]` on the device BEAM and produced all-black output. Switched the contract to a positional list so the shader-side `Uniforms` struct member order is what the user wrote. The map form is still accepted as a backward-compat fallback (the fragment shader struct has to match whatever order the runtime emits) but is documented as not recommended. ## What v1 doesn't cover * Android (planned next — `GLSurfaceView` + GLES 3.0 mirror) * GLSL → MSL build-time transpilation via SPIRV-Cross/glslang (planned; lets a user write one shader and run on both) * Textures / multi-pass / float3 / vertex-shader override * Compute shaders Each is a separate seam — none required for fragment-shader demos (Mandelbrot, n-body, shader art, distance-field rendering, scientific viz). ## What ships * `Mob.UI.gpu_view/1` — `:id`, `:width`, `:height`, `:shader`, `:uniforms`, `:on_tap`/`:on_drag`/`:on_pinch`. * `` sigil tag whitelisted in priv/tags/ios.txt. * `MobNodeTypeGpuView` + `gpuShaderMSL` + `gpuUniforms` on MobNode. * Prop parsing in mob_nif.m for both list-of-values and dict uniforms. * `MobGpuView.swift` — UIViewRepresentable wrapping a self- delegating MTKView. Compiles shaders on demand (hash-keyed cache), packs uniforms at natural MSL alignment, surfaces shader compile errors as a translucent red overlay on top of the view. * Dispatch in MobRootView.swift's case .gpuView. Tests: * `Mob.UITest` — eight new gpu_view/1 cases covering type, children, prop forwarding, the shader-as-map escape hatch, keyword-vs-map parity, on_tap/drag/pinch passthrough, and the list-preserves-order guarantee with a comment recording the empirical Map-iteration-order failure mode. * `Mob.SigilTest` — `` resolves to `:gpu_view` and is on the iOS whitelist (so a future removal from priv/tags/ios.txt causes a loud warning that the existing stderr capture tests will surface). Mob_new template changes for the new Swift source live in the mob_new repo alongside this commit (companion edit to the two iOS build.zig.eex templates). Verified end-to-end on iPhone via the Mandelbrot demo project (generated with `mix mob.new mandelbrot_demo --local`, `MOB_DIR=`). --- ios/MobGpuView.swift | 317 ++++++++++++++++++++++++++++++++++++++++ ios/MobNode.h | 13 ++ ios/MobRootView.swift | 6 + ios/mob_nif.m | 20 +++ lib/mob/ui.ex | 91 ++++++++++++ priv/tags/ios.txt | 1 + test/mob/sigil_test.exs | 9 ++ test/mob/ui_test.exs | 112 ++++++++++++++ 8 files changed, 569 insertions(+) create mode 100644 ios/MobGpuView.swift diff --git a/ios/MobGpuView.swift b/ios/MobGpuView.swift new file mode 100644 index 00000000..0f940cd6 --- /dev/null +++ b/ios/MobGpuView.swift @@ -0,0 +1,317 @@ +// MobGpuView.swift — Metal-backed fragment-shader surface. +// +// Hosts an `MTKView` inside a SwiftUI `UIViewRepresentable`. Compiles the +// MSL fragment shader supplied by the BEAM into a render pipeline, binds +// per-frame uniforms into fragment buffer slot 0, and renders a +// full-screen quad at the display's refresh rate. +// +// Scope (v1): +// - Fragment-shader-only. Built-in vertex shader emits a full-screen +// NDC quad with a (0..1, 0..1) `uv` in `VertexOut.uv`. +// - Uniforms map keys become member names in the `Uniforms` struct. +// Supported types: float (NSNumber), float2/3/4 (NSArray of 2/3/4 +// numbers), uint (NSNumber promoted to integer). The uniform struct +// layout is a flat sequence of 16-byte-aligned slots — matches MSL's +// default alignment for vec types. +// - Shader compile errors surface as a translucent red overlay with the +// error message, on top of the (black) Metal view. +// +// Not yet: +// - Textures (camera frame, ML output) as samplers +// - Vertex shader override / custom mesh +// - GLSL → MSL transpilation (escape hatch via the BEAM-side +// %{ios: "..."} map form is the workaround; transpile is a future task) + +import Foundation +import Metal +import MetalKit +import SwiftUI + +// MARK: - SwiftUI wrapper + +struct MobGpuView: UIViewRepresentable { + let node: MobNode + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> MobGpuMTKView { + let view = MobGpuMTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + view.backgroundColor = .black + view.colorPixelFormat = .bgra8Unorm + view.framebufferOnly = false + view.preferredFramesPerSecond = 60 + view.isPaused = false + view.enableSetNeedsDisplay = false // continuous mode + view.delegate = view // self-delegate; renderer logic lives in MobGpuMTKView + return view + } + + func updateUIView(_ view: MobGpuMTKView, context: Context) { + if let shader = node.gpuShaderMSL { + view.setShader(shader) + } else { + view.setShader(nil) + } + view.setUniforms(node.gpuUniforms ?? []) + } + + final class Coordinator {} +} + +// MARK: - MTKView subclass + renderer + +/// A self-delegating MTKView that compiles MSL fragment shaders on demand +/// and renders a full-screen quad with caller-supplied uniforms. +final class MobGpuMTKView: MTKView, MTKViewDelegate { + // Compiled shader pipeline (nil until first valid shader arrives). + private var pipelineState: MTLRenderPipelineState? + private var commandQueue: MTLCommandQueue? + private var compileError: String? + private var currentShaderHash: Int = 0 + private var uniformBuffer: MTLBuffer? + private var uniformBytes = Data() + + // SwiftUI host for the error overlay. Rendered as a UILabel pinned to + // the top-left so the user sees compile errors inline. + private weak var errorLabel: UILabel? + + override init(frame frameRect: CGRect, device: MTLDevice?) { + super.init(frame: frameRect, device: device) + self.commandQueue = device?.makeCommandQueue() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + self.commandQueue = self.device?.makeCommandQueue() + } + + // MARK: shader handoff from SwiftUI + + func setShader(_ source: String?) { + guard let source = source, !source.isEmpty else { + if pipelineState != nil { pipelineState = nil; showError(nil) } + return + } + let hash = source.hashValue + if hash == currentShaderHash, pipelineState != nil { return } + currentShaderHash = hash + compileShader(source) + } + + func setUniforms(_ uniforms: Any) { + // Uniforms arrive as a top-level NSArray (BEAM-side list) — packed + // in declaration order so the order survives JSON round-trip and + // map-iteration surprises. Each element is either: + // - NSNumber (float or int → 4-byte slot at natural alignment) + // - NSArray of 2 numbers (float2 → 8-byte slot at 8-byte align) + // - NSArray of 4 numbers (float4 → 16-byte slot at 16-byte align) + // + // The shader then declares its `Uniforms` struct with members in + // the SAME order: + // + // struct Uniforms { + // float2 center; // matches uniforms[0] + // float zoom; // matches uniforms[1] + // uint max_iter; // matches uniforms[2] + // }; + // + // (Map form was tempting but Elixir map iteration order is + // not stable beyond ~32 entries and differs across runtimes — + // discovered this empirically when the demo rendered black on + // device because :zoom came first on iOS BEAM.) + var data = Data() + if let list = uniforms as? [Any] { + for value in list { + appendUniformValue(value, to: &data) + } + } else if let dict = uniforms as? [AnyHashable: Any] { + // Fallback for backward compat — iteration order undefined. + // The shader-side struct MUST match whatever the runtime decides. + // Not recommended; use the list form above. + for (_, value) in dict { + appendUniformValue(value, to: &data) + } + } + uniformBytes = data + if data.count > 0 { + uniformBuffer = device?.makeBuffer(bytes: (data as NSData).bytes, length: data.count, options: []) + } else { + uniformBuffer = nil + } + } + + private func appendUniformValue(_ value: Any, to data: inout Data) { + if let n = value as? NSNumber { + let typeStr = String(cString: n.objCType) + if typeStr == "q" || typeStr == "l" || typeStr == "i" { + alignTo(4, in: &data) + var v: UInt32 = UInt32(truncatingIfNeeded: n.int64Value) + data.append(Data(bytes: &v, count: 4)) + } else { + alignTo(4, in: &data) + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + return + } + if let arr = value as? [Any] { + switch arr.count { + case 2: + alignTo(8, in: &data) + for i in 0..<2 { + if let n = arr[i] as? NSNumber { + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + } + case 4: + alignTo(16, in: &data) + for i in 0..<4 { + if let n = arr[i] as? NSNumber { + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + } + default: + // Unsupported arity (3 reserved for future float3, + // others unhandled). Skip silently — shader-side will + // read garbage, which is at least localizable in a debug. + break + } + } + } + + private func alignTo(_ alignment: Int, in data: inout Data) { + let mod = data.count % alignment + if mod != 0 { data.append(Data(count: alignment - mod)) } + } + + // MARK: compile + + private func compileShader(_ source: String) { + guard let device = device else { return } + + let full = """ + \(vertexSource) + \(source) + """ + + do { + let library = try device.makeLibrary(source: full, options: nil) + guard let vertexFn = library.makeFunction(name: "vertex_main") else { + showError("internal: vertex_main not found in built-in vertex source") + return + } + // Convention: fragment entry point is called `fragment_main`. If + // the supplied shader exports a function with a different name, + // make_function returns nil and we surface that to the user. + guard let fragmentFn = library.makeFunction(name: "fragment_main") else { + showError("fragment_main not found — your shader must define `fragment half4 fragment_main(VertexOut in [[stage_in]], constant Uniforms& u [[buffer(0)]])`") + return + } + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = vertexFn + desc.fragmentFunction = fragmentFn + desc.colorAttachments[0].pixelFormat = colorPixelFormat + pipelineState = try device.makeRenderPipelineState(descriptor: desc) + showError(nil) + } catch { + pipelineState = nil + showError(String(describing: error)) + } + } + + private var vertexSource: String { + // Full-screen quad in clip space + a passthrough uv in (0..1, 0..1). + // The fragment shader writes `Uniforms` member layout itself; we + // don't generate the struct here. + return """ + #include + using namespace metal; + + struct VertexOut { + float4 position [[position]]; + float2 uv; + }; + + vertex VertexOut vertex_main(uint vid [[vertex_id]]) { + // Quad as a triangle strip: BL, BR, TL, TR + float2 pos[4] = { + float2(-1.0, -1.0), + float2( 1.0, -1.0), + float2(-1.0, 1.0), + float2( 1.0, 1.0) + }; + float2 uv[4] = { + float2(0.0, 1.0), + float2(1.0, 1.0), + float2(0.0, 0.0), + float2(1.0, 0.0) + }; + VertexOut out; + out.position = float4(pos[vid], 0.0, 1.0); + out.uv = uv[vid]; + return out; + } + """ + } + + // MARK: error overlay + + private func showError(_ message: String?) { + compileError = message + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let message = message { + if self.errorLabel == nil { + let label = UILabel(frame: self.bounds) + label.numberOfLines = 0 + label.font = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular) + label.textColor = .white + label.backgroundColor = UIColor.red.withAlphaComponent(0.7) + label.lineBreakMode = .byWordWrapping + label.textAlignment = .left + label.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: self.topAnchor), + label.leadingAnchor.constraint(equalTo: self.leadingAnchor), + label.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + self.errorLabel = label + } + self.errorLabel?.text = "shader error:\n\(message)" + self.errorLabel?.isHidden = false + } else { + self.errorLabel?.isHidden = true + } + } + } + + // MARK: MTKViewDelegate + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} + + func draw(in view: MTKView) { + guard let pipeline = pipelineState, + let cmdBuf = commandQueue?.makeCommandBuffer(), + let renderPass = currentRenderPassDescriptor, + let drawable = currentDrawable, + let encoder = cmdBuf.makeRenderCommandEncoder(descriptor: renderPass) + else { + // Either no shader compiled yet or pipeline failed — let the + // overlay (if any) speak for itself; nothing to draw. + currentDrawable?.present() + return + } + + encoder.setRenderPipelineState(pipeline) + if let buf = uniformBuffer { + encoder.setFragmentBuffer(buf, offset: 0, index: 0) + } + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + cmdBuf.present(drawable) + cmdBuf.commit() + } +} diff --git a/ios/MobNode.h b/ios/MobNode.h index c4242c83..8f8ba9a4 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -37,6 +37,7 @@ typedef NS_ENUM(NSInteger, MobNodeType) { MobNodeTypeNativeView, MobNodeTypeIcon, MobNodeTypeCanvas, + MobNodeTypeGpuView, }; NS_ASSUME_NONNULL_BEGIN @@ -237,6 +238,18 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic) CGFloat canvasWidth; // pt; required (>0) @property(nonatomic) CGFloat canvasHeight; // pt; required (>0) +// GpuView — Metal shader source + per-frame uniforms. The native side +// compiles `gpuShaderMSL` into an MTLRenderPipelineState (cached by +// the source hash) and binds `gpuUniforms` to fragment buffer slot 0 +// every frame. Shader compile errors surface as a translucent overlay +// on top of the view. See `Mob.UI.gpu_view/1` for the BEAM-side +// contract and the iOS-only / MSL-only scope. +@property(nonatomic, copy, nullable) NSString *gpuShaderMSL; +// May be an NSArray (preferred — ordered uniform list) or NSDictionary +// (legacy — iteration order undefined). See MobGpuView.swift for the +// expected packing semantics per element. +@property(nonatomic, strong, nullable) id gpuUniforms; + // Children @property(nonatomic, strong, nonnull) NSMutableArray *children; diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 1f275098..52fa67e1 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -443,6 +443,12 @@ struct MobNodeView: View { MobCanvasView(node: node) .padding(node.paddingEdgeInsets) + case .gpuView: + MobGpuView(node: node) + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } + .padding(node.paddingEdgeInsets) + @unknown default: EmptyView() } diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 3975d7e8..0fc43bc3 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -636,6 +636,8 @@ static void mob_send_change_float(int handle, double value) { node.nodeType = MobNodeTypeIcon; else if ([type isEqualToString:@"canvas"]) node.nodeType = MobNodeTypeCanvas; + else if ([type isEqualToString:@"gpu_view"]) + node.nodeType = MobNodeTypeGpuView; NSDictionary *props = dict[@"props"]; if ([props isKindOfClass:[NSDictionary class]]) { @@ -1090,6 +1092,24 @@ static void mob_send_change_float(int handle, double value) { if (canvasH && node.nodeType == MobNodeTypeCanvas) node.canvasHeight = [canvasH doubleValue]; + // gpu_view props: shader (string OR %{ios: "..."} map) + uniforms map. + // Map form is the "I already have hand-tuned MSL" escape hatch. + if (node.nodeType == MobNodeTypeGpuView) { + id shader = props[@"shader"]; + if ([shader isKindOfClass:[NSString class]]) { + node.gpuShaderMSL = shader; + } else if ([shader isKindOfClass:[NSDictionary class]]) { + id iosShader = ((NSDictionary *)shader)[@"ios"]; + if ([iosShader isKindOfClass:[NSString class]]) + node.gpuShaderMSL = iosShader; + } + + id uniforms = props[@"uniforms"]; + if ([uniforms isKindOfClass:[NSArray class]] || + [uniforms isKindOfClass:[NSDictionary class]]) + node.gpuUniforms = uniforms; + } + // webview props id webViewUrl = props[@"url"]; if ([webViewUrl isKindOfClass:[NSString class]]) diff --git a/lib/mob/ui.ex b/lib/mob/ui.ex index a546144f..f8136f5c 100644 --- a/lib/mob/ui.ex +++ b/lib/mob/ui.ex @@ -164,4 +164,95 @@ defmodule Mob.UI do children: [] } end + + @doc """ + Returns a `:gpu_view` leaf node — a fragment-shader-driven GPU surface + backed by `MTKView` + Metal on iOS. The native side compiles the + supplied shader (Metal Shading Language) into a render pipeline, binds + the supplied uniforms in declaration order at fragment buffer slot 0, + and renders a full-screen quad at the display refresh rate. + + Android support (`GLSurfaceView` + GLES 3.0) is not in v1. + + ## Props + + * `:id` — required atom that identifies the GPU view across re-renders + (so the native side keeps the same Metal pipeline / texture cache). + * `:width` / `:height` — pt/dp, required. + * `:shader` — either a string of Metal Shading Language source (iOS), + or a map `%{ios: "...MSL..."}` (escape hatch — same as the string + form; the map form exists so future platforms can be added without + breaking the API). + * `:uniforms` — an **ordered list of values** packed into the shader's + `Uniforms` struct in declaration order. Each element is one of: + * a number — `float` (or `uint` if integer-typed at the BEAM level) + * a 2-element list `[a, b]` — `float2` + * a 4-element list `[a, b, c, d]` — `float4` + (`float3` deliberately not supported in v1 — its 16-byte + alignment with 12-byte size makes the layout API messier than + it's worth here.) + + Shader compile errors are caught natively and surfaced as a translucent + overlay on top of the GpuView with the error message. + + ## Why a list, not a map + + Elixir map iteration order is **not stable** across runtimes or map + sizes — `%{a: 1, b: 2, c: 3}` can iterate in any order. The natural + MSL layout for a `Uniforms` struct is positional, so we mirror that + on the BEAM side. List position 0 → first struct member, etc. + + A map form is still accepted as a backward-compat fallback but will + pack in whatever order the runtime decides, so the shader-side struct + has to match an unstable order — not recommended. + + ## Example — Mandelbrot at the display's refresh rate + + @shader File.read!("priv/shaders/mandelbrot.metal") + + Mob.UI.gpu_view( + id: :mandelbrot, + width: 350, + height: 350, + shader: @shader, + # MSL: struct Uniforms { float2 center; float zoom; uint max_iter; }; + uniforms: [[cx, cy], zoom, max_iter] + ) + + ## What the framework auto-provides + + The host emits a built-in vertex shader that draws a full-screen quad + and produces a `VertexOut { float4 position [[position]]; float2 uv; }`. + Your fragment shader receives that as `[[stage_in]]` and reads + `in.uv` (0..1 across the view) plus the user uniforms at buffer slot 0. + Don't redeclare `VertexOut`, `vertex_main`, or the metal_stdlib include + in your shader — the host prepends them. + + ## Required fragment entry point + + Your shader must export `fragment_main`: + + fragment half4 fragment_main(VertexOut in [[stage_in]], + constant Uniforms& u [[buffer(0)]]) { ... } + """ + @spec gpu_view(keyword() | map()) :: map() + def gpu_view(props) when is_list(props), do: gpu_view(Map.new(props)) + + def gpu_view(%{} = props) do + %{ + type: :gpu_view, + props: + Map.take(props, [ + :id, + :width, + :height, + :shader, + :uniforms, + :on_tap, + :on_drag, + :on_pinch + ]), + children: [] + } + end end diff --git a/priv/tags/ios.txt b/priv/tags/ios.txt index 5934b74a..0380a373 100644 --- a/priv/tags/ios.txt +++ b/priv/tags/ios.txt @@ -22,3 +22,4 @@ Toggle Video CameraPreview WebView +GpuView diff --git a/test/mob/sigil_test.exs b/test/mob/sigil_test.exs index a934225a..293ac160 100644 --- a/test/mob/sigil_test.exs +++ b/test/mob/sigil_test.exs @@ -199,6 +199,15 @@ defmodule Mob.SigilTest do node = ~MOB() assert node.type == :text_field end + + test "GpuView resolves to :gpu_view (and is on the iOS whitelist)" do + # If GpuView drops off priv/tags/ios.txt, the sigil emits a + # compile-time warning and the test breaks loudly via the stderr + # capture used elsewhere in this file. For the type atom alone, + # this just checks the snake_case conversion. + node = ~MOB() + assert node.type == :gpu_view + end end # ── parity with raw maps ───────────────────────────────────────────────────── diff --git a/test/mob/ui_test.exs b/test/mob/ui_test.exs index d55de24a..90ace7fe 100644 --- a/test/mob/ui_test.exs +++ b/test/mob/ui_test.exs @@ -98,4 +98,116 @@ defmodule Mob.UITest do assert UI.canvas(width: 100, height: 100, draw: ops).props.draw == ops end end + + describe "gpu_view/1" do + @shader """ + fragment half4 fragment_main(VertexOut in [[stage_in]], + constant Uniforms& u [[buffer(0)]]) { + return half4(in.uv, 0.0, 1.0); + } + """ + + test "type is :gpu_view" do + node = UI.gpu_view(id: :mandelbrot, width: 350, height: 350, shader: @shader, uniforms: []) + assert node.type == :gpu_view + end + + test "children is always empty — gpu_view is a leaf node" do + node = UI.gpu_view(id: :mandelbrot, width: 350, height: 350, shader: @shader, uniforms: []) + assert node.children == [] + end + + test "props carries id / width / height / shader / uniforms verbatim" do + uniforms = [[1.0, 2.0], 3.0, 256] + + props = + UI.gpu_view( + id: :foo, + width: 200, + height: 150, + shader: @shader, + uniforms: uniforms + ).props + + assert props.id == :foo + assert props.width == 200 + assert props.height == 150 + assert props.shader == @shader + assert props.uniforms == uniforms + end + + test "accepts shader as the map escape-hatch form" do + shader_map = %{ios: @shader} + props = UI.gpu_view(id: :x, width: 100, height: 100, shader: shader_map, uniforms: []).props + assert props.shader == shader_map + end + + test "unrecognized props are omitted" do + props = + UI.gpu_view( + id: :x, + width: 100, + height: 100, + shader: @shader, + uniforms: [], + background: "#000" + ).props + + refute Map.has_key?(props, :background) + end + + test "accepts a plain map and produces identical output to the keyword form" do + kw = + UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: [1.0]) + + m = + UI.gpu_view(%{id: :x, width: 100, height: 100, shader: @shader, uniforms: [1.0]}) + + assert kw == m + end + + test "shape is renderer-compatible — %{type:, props:, children:}" do + node = UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: []) + assert Map.keys(node) |> Enum.sort() == [:children, :props, :type] + end + + test "carries on_tap / on_drag / on_pinch when supplied" do + tap = {self(), :tapped} + drag = {self(), :dragged} + pinch = {self(), :pinched} + + props = + UI.gpu_view( + id: :x, + width: 100, + height: 100, + shader: @shader, + uniforms: [], + on_tap: tap, + on_drag: drag, + on_pinch: pinch + ).props + + assert props.on_tap == tap + assert props.on_drag == drag + assert props.on_pinch == pinch + end + + test "uniforms list preserves declaration order (no Map iteration surprises)" do + # The whole point of accepting a list — order is pinned to position, + # not to whatever the runtime decides. The shader-side `Uniforms` + # struct can declare its members in the same order and read them + # verbatim. A map form does not give this guarantee (verified + # empirically against the iPhone Mandelbrot demo, where + # `%{center: ..., zoom: ..., max_iter: ...}` iterated as + # `[:zoom, :max_iter, :center]` on the device BEAM and produced + # black output until we switched to a list). + uniforms = [[1.0, 2.0], 3.0, 256, [4.0, 5.0, 6.0, 7.0]] + + props = + UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: uniforms).props + + assert props.uniforms == uniforms + end + end end From fb1ddfc4e1cd99986cd3aa496634a586f9f89b8a Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 22:19:59 -0600 Subject: [PATCH 071/254] MobGpuView: wrap long swiftlint line in the fragment_main error message --- ios/MobGpuView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ios/MobGpuView.swift b/ios/MobGpuView.swift index 0f940cd6..0b8194f9 100644 --- a/ios/MobGpuView.swift +++ b/ios/MobGpuView.swift @@ -206,7 +206,11 @@ final class MobGpuMTKView: MTKView, MTKViewDelegate { // the supplied shader exports a function with a different name, // make_function returns nil and we surface that to the user. guard let fragmentFn = library.makeFunction(name: "fragment_main") else { - showError("fragment_main not found — your shader must define `fragment half4 fragment_main(VertexOut in [[stage_in]], constant Uniforms& u [[buffer(0)]])`") + showError( + "fragment_main not found — your shader must define " + + "`fragment half4 fragment_main(VertexOut in [[stage_in]], " + + "constant Uniforms& u [[buffer(0)]])`" + ) return } let desc = MTLRenderPipelineDescriptor() From 9857a943d21cfae6cf5604b1939053a950100488 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 22:22:57 -0600 Subject: [PATCH 072/254] Mob.GpuView: whitelist tag for Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the iOS Metal implementation: when an Android app's MobBridge.kt template gains the matching GLSurfaceView + GLES 3.0 renderer (in mob_new), the sigil compiler will accept without emitting an unknown-tag warning. The actual Android rendering implementation lives in the mob_new template (per the existing convention for native UI components like WebView, CameraPreview, Canvas) — this file just gates which tags the sigil whitelists for the platform. --- priv/tags/android.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/tags/android.txt b/priv/tags/android.txt index bfa77a0f..06fbf07c 100644 --- a/priv/tags/android.txt +++ b/priv/tags/android.txt @@ -23,3 +23,4 @@ Toggle Video CameraPreview WebView +GpuView From def537878472c5b01135cfb8be30a5cc1de54d26 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 23:08:48 -0600 Subject: [PATCH 073/254] mob_nif.zig: use enif_make_list_from_array for empty BT paired list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mob_deliver_bt_paired_list_finish/1` (added by the BT PR) seeded its cons-cdr accumulator with `erts.enif_make_list(env, 0)`, but enif_make_list is variadic in C and intentionally not exposed in mob_erts.zig (see the binding-region comment that explains the non-variadic make_list_from_array / make_list_cell alternatives we do expose). The Android arm64 build failed at link with `mob_erts' has no member named 'enif_make_list'`. Swap in `enif_make_list_from_array(env, &empty, 0)` which returns the same empty-list term via the non-variadic ABI. Found by deploying mandelbrot_demo to the Android emulator — this codepath isn't exercised by mix test (Zig source isn't compiled during the Elixir test suite), so it surfaced only at link time. --- android/jni/mob_nif.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 87327d72..8b125d81 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -3649,7 +3649,12 @@ pub export fn mob_deliver_bt_paired_list_finish(pid_long: jni.JLong) callconv(.c const env = erts.enif_alloc_env() orelse return; defer erts.enif_free_env(env); - var list = erts.enif_make_list(env, 0); + // Empty list as the cons-cdr seed. `enif_make_list` is variadic in + // C and intentionally not exposed in mob_erts.zig (see the comment + // on the make_list bindings); `enif_make_list_from_array` with + // count=0 returns the same empty-list term via the non-variadic ABI. + const empty: [0]erts.ERL_NIF_TERM = .{}; + var list = erts.enif_make_list_from_array(env, &empty, 0); var i: usize = snapshot.count; while (i > 0) { i -= 1; From a80408eeb70c2f881aa14d2fd0ef7318e21be17e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 23:29:42 -0600 Subject: [PATCH 074/254] PLAN.md: add CI + integration-test plan (3 layers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the gap that surfaced when this session's mandelbrot_demo deploy caught 5 latent native-compile bugs across two recently-merged PRs that mix test missed. Three layers, ordered by effort/coverage ratio: 1. Per-repo unit-test CI (1-2 hours total) — mix test / format / credo on push + PR across mob, mob_dev, mob_new. Doesn't catch native bugs but plugs the obvious gap (mob_dev + mob_new have no CI at all today). 2. Native-build smoke (4-8 hours) — actually run mix mob.new + mix mob.deploy --native against an Android emulator in CI. Would catch every bug from this session. Cycle-time too slow for every push; PR + nightly cron. 3. Per-component screenshot diffs (1-2 days) — render , , etc. and pixel-hash assert. Pays off once Layer 2 is in place. Includes effort table and the few open questions worth deciding before starting (required vs informational, where each repo's CI runs, caching strategy). --- PLAN.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/PLAN.md b/PLAN.md index 39bdcb42..4ad3be09 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2252,3 +2252,130 @@ If batch 5+ benchmarks show meaningful overhead, add per-category enable so subscribers only register OS observers they actually use. For batches 1–4 this isn't worth the API surface — the cost is dominated by the OS firing the notification, which happens regardless of whether we observe. + +--- + +## CI & integration testing + +### Status quo (2026-05-15) + +- **mob**: `.github/workflows/onboarding.yml` exists. Runs the `test/onboarding/` + suite (generator tests + simulator/emulator-driven device tests) on push to + main, PRs touching `lib/**` / `priv/templates/**` / `test/onboarding/**`, + and nightly cron. **Does not run the plain `mix test` suite.** Local + developer-run only. +- **mob_dev**: no CI. ~1,338 tests run locally only. +- **mob_new**: no CI. ~237 tests run locally only. + +### The gap this session surfaced + +A round-trip deploy of `mandelbrot_demo` to the iPhone + Android emulator +caught **five latent bugs across two recently-merged PRs** in one pass: + + * `mob#9` (Bluetooth Classic, HeroesLament) — `enif_make_list` not bound in + `mob_erts.zig`; Android arm64 link failure. + * `mob_new#4` (BT templates, HeroesLament) — missing `}` before the BT JNI + thunks; every subsequent `JNIEXPORT void JNICALL` rejected by clang. + * `mob_new#4` — duplicate Kotlin imports (`IntentFilter`, + `ConcurrentHashMap`, `AtomicInteger`); kotlinc "Conflicting import". + * GpuView Android template — missing `androidx.compose.foundation.layout.fillMaxSize` + import; kotlinc unresolved reference. + * GpuView Android template — orphan comment in the import block tripped + ktlint's `import-ordering` rule. + +All five passed `mix test` and `mix credo --strict` on both repos. They only +surface when the toolchain actually runs — and currently the toolchain only +runs on a developer's laptop during a manual `mix mob.deploy`. By the time +five things had piled up, the diff to bisect was non-trivial. + +The pattern: **mob's test suite intentionally doesn't compile native code** +(Zig / Kotlin / C). Generator tests render templates and grep for strings, +which catches refactor drift but not syntactic regressions. + +### Three-layer plan + +#### Layer 1: per-repo unit-test CI (≈1–2 hours total) + +Add a `.github/workflows/test.yml` to each of `mob`, `mob_dev`, `mob_new` +that runs on push and PR: + +```yaml +- erlef/setup-beam@v1 # OTP + Elixir +- mix deps.get +- mix compile --warnings-as-errors +- mix format --check-formatted +- mix credo --strict +- mix test +- (mob only) mix erlfmt --check src/ +- (mob only) xcrun clang-format --dry-run -Werror … +``` + +Sharing details: +- `actions/cache` on `deps/` and `_build/test/` keyed by `mix.lock`. +- Run on `ubuntu-latest` for everything except the iOS/Swift bits (which + need macOS); the existing onboarding workflow already pays the macos-15 + premium for full device tests — we don't need to for the Elixir suite. +- mob_new tests do `mix phx.new lv_test` under the hood (40+ sec/run); CI + time ~3 minutes per run. Acceptable. + +This catches: every regression the local `mix test` would catch, plus +contributors who don't run the formatters / credo locally. + +Does **not** catch the 5 bugs above — those needed an actual compile. + +#### Layer 2: native-build smoke test (≈4–8 hours) + +A separate job that runs less frequently (PR only or nightly cron) and +actually compiles the generated project: + +```yaml +# After test.yml passes: +- mix mob.new ci_smoke --local +- mix mob.install +- cd ci_smoke && mix mob.deploy --native --android --device emulator-XXXX +- # Boot Android emulator via reactivecircus/android-emulator-runner +- # Use mob.connect + Mob.Test.screen/1 to assert the home screen mounts +``` + +This catches the Bluetooth / GpuView class of bug because Gradle / kotlinc / +zig actually run. **Costs roughly 10 minutes per run** (Android emulator +boot is the dominant cost) — too expensive for every push, but worth +running on PR to `master` and nightly. + +The existing onboarding workflow's `with-devices` job is structurally close +to this; could be extended rather than building from scratch. + +#### Layer 3: behavioural integration (already partial) + +`test/onboarding/failure_modes_test.exs` + the `with-devices` matrix +already exercise multi-device deploys against a real simulator/emulator. +The current scope is install/deploy/doctor — not per-component rendering +behaviour. + +Future work: add Mandelbrot-style "render this thing, screenshot it, +assert the pixel-hash matches a baseline" tests for each native component +(``, ``, ``, ``). Mostly a +question of writing a baseline harness; the screenshot+assert infrastructure +exists in `Mob.Test`. Probably 1–2 days for the first three components, +then ~1 hour per additional component. + +### Effort summary + +| Layer | Effort | Coverage | Priority | +|---|---|---|---| +| 1 — unit-test CI on 3 repos | 1–2 hours | Elixir-level regressions, formatter drift | **High** — biggest signal-per-hour win | +| 2 — native-build smoke (Android + iOS) | 4–8 hours | Native compile bugs (this session's 5) | Medium — recurring source of "merged but broken" | +| 3 — per-component screenshot diffs | 1–2 days | Renderer / native-bridge regressions | Lower — pays off once Layer 2 exists | + +### Open questions before starting + +- **Required vs informational checks?** Layer 1 should probably block PR + merge. Layer 2 cycle time (~10 min) makes it borderline; might be + "informational" with a clear failure summary in the PR. +- **Where do mob_dev / mob_new tests run for cost?** Both can stay on + ubuntu-latest; the only macOS-required bits are iOS simulator + Xcode + toolchain, which Layer 2 needs. +- **Caching strategy.** `deps/` is straightforward. `_build/test/` for + Elixir is cheap to recompute (~30s) so cache is nice but not essential. + Android SDK download is slow (~2 min cold); cache that aggressively in + Layer 2. From edf02991308ed327d1350d58cf5711d6e5afa7d8 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 15 May 2026 23:57:46 -0600 Subject: [PATCH 075/254] ci+docs: add tests + release workflows, CHANGELOG.md, fix HexDocs source links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions tied together because they all support a more release-ready posture: * `.github/workflows/test.yml` — runs `mix test`, format check, `mix credo --strict`, `mix erlfmt --check src/`, a macOS-runner `xcrun clang-format` + `swiftlint` job, and `mix deps.audit` on every push to master and every PR. Caches `deps/` + `_build/test/` keyed on `mix.lock`. swiftlint and mix_audit are `continue-on-error` until pre-existing findings get triaged (force_cast in MobRootView.swift; nothing visible in the lock today). * `.github/workflows/release.yml` — on tag push (matching X.Y.Z or X.Y.Z-prerelease), creates a GitHub Release. Body comes from the matching `## []` section of CHANGELOG.md, falling back to auto-generated commit notes if the section is missing. * `CHANGELOG.md` — Keep-a-Changelog format, points at hexdocs for full module reference. Backfilled entries for 0.6.2, 0.6.3, 0.6.4; Unreleased section captures doc-link fix + Zig enif_make_list bug + the CI workflows themselves. Docs polish in the same commit since CHANGELOG.md is wired in: * `source_url_pattern` corrected — was pointing at `/blob/main/...` but the repo's default branch is `master`, so every `` glyph next to a heading in the rendered docs 404'd. Now opens the actual source file. * CHANGELOG.md added to the `extras:` list so it lands in the HexDocs sidebar alongside README and the guides, and added to `files:` so the hex package ships it. --- .github/workflows/release.yml | 59 +++++++++++++++++ .github/workflows/test.yml | 121 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 43 ++++++++++++ mix.exs | 5 +- 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6a262ad4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: release + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+-*' + +# Only one release run per tag at a time. Cancelling in-progress is +# deliberately OFF — a release in flight should always finish. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # required to create a GitHub Release + +jobs: + github_release: + name: Create GitHub Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history so action-gh-release can build a changelog from the + # commits between this tag and the previous one. + fetch-depth: 0 + + - name: Extract CHANGELOG section for this tag + id: changelog + run: | + if [ -f CHANGELOG.md ]; then + # Pull the section under "## []" or "## " up to the + # next "## " heading. If nothing matches, the body falls back + # to auto-generated commit notes. + awk -v tag="${{ github.ref_name }}" ' + $0 ~ "^## \\[" tag "\\]" || $0 ~ "^## " tag "( |$)" { in_section=1; next } + in_section && /^## / { exit } + in_section { print } + ' CHANGELOG.md > /tmp/release-body.md + if [ -s /tmp/release-body.md ]; then + echo "has_body=true" >> "$GITHUB_OUTPUT" + else + echo "has_body=false" >> "$GITHUB_OUTPUT" + fi + else + echo "has_body=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + # If CHANGELOG.md had a matching section, use it. Otherwise let + # the action auto-generate notes from the commit log since the + # last tag. + body_path: ${{ steps.changelog.outputs.has_body == 'true' && '/tmp/release-body.md' || '' }} + generate_release_notes: ${{ steps.changelog.outputs.has_body != 'true' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..03ec1fd8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,121 @@ +name: tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }} + runs-on: ubuntu-latest + env: + MIX_ENV: test + strategy: + fail-fast: false + matrix: + include: + - elixir: '1.19' + otp: '28' + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Cache deps + _build + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}- + + - run: mix deps.get + - run: mix deps.compile + - run: mix compile --warnings-as-errors + + - name: Format check + run: mix format --check-formatted + + - name: Credo (strict) + run: mix credo --strict + + - name: erlfmt check (src/) + run: mix erlfmt --check src/ + + - name: Tests + run: mix test + + native_lint: + name: Native formatters (clang-format + swiftlint) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: clang-format (iOS Objective-C + Android JNI headers) + # xcrun ships clang-format with the Xcode CLT, which the macos-15 + # runner already has — no install step. + run: | + xcrun clang-format --dry-run -Werror \ + ios/mob_nif.m \ + android/jni/mob_beam.h + + - name: Install swiftlint + run: brew install swiftlint + + - name: swiftlint + # swiftlint emits warnings for some pre-existing patterns + # (force_cast in MobRootView.swift). Treat the run as + # informational until those are triaged; flip to --strict later. + continue-on-error: true + run: swiftlint lint --reporter github-actions-logging ios/ + + security_scan: + name: Security scan (mix_audit) + needs: test + runs-on: ubuntu-latest + if: always() + # Informational: a finding shouldn't block the workflow gate while we + # establish a baseline. Flip to fail-on-vuln once we've triaged what's + # already in the dep tree. + continue-on-error: true + env: + MIX_ENV: test + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: '1.19' + otp-version: '28' + + - name: Cache deps + _build (reuse test job's key) + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-${{ runner.os }}-1.19-28-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-${{ runner.os }}-1.19-28- + + - run: mix deps.get + - run: mix deps.compile + + # mix_audit scans mix.lock against the Erlef advisory feed. Lightweight + # entry-level scan; the richer multi-layer scan lives in mob_dev's + # `mix mob.security_scan` (we don't depend on mob_dev here to avoid a + # dev-only dependency cycle). + - name: Install mix_audit + run: mix archive.install hex mix_audit --force + + - name: Audit deps + run: mix deps.audit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..aa31eb73 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to **mob** are documented here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html). + +Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). + +--- + +## [Unreleased] + +### Fixed +- HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `` glyph next to a heading now opens the actual source file in the GitHub repo. +- `mob_nif.zig` called the variadic `enif_make_list/2` (not exposed in `mob_erts.zig`) from the BT paired-list finisher; the Android arm64 build failed at link. Switched to the non-variadic `enif_make_list_from_array(env, &empty, 0)`. + +### Added +- `.github/workflows/test.yml` — runs `mix test`, `mix format --check-formatted`, `mix credo --strict`, `mix erlfmt --check src/`, `xcrun clang-format`, `swiftlint`, and `mix deps.audit` on push to master and on every PR. +- `.github/workflows/release.yml` — on tag push, creates a GitHub Release whose body is the matching `## [X.Y.Z]` section from this changelog (falls back to auto-generated commit notes if the tag has no section). +- `PLAN.md` — three-layer CI + integration-test plan covering the gap between unit tests and on-device verification. + +## [0.6.4] + +### Added +- `Mob.GpuView` / `Mob.UI.gpu_view/1` — Metal fragment-shader surface on iOS. Host owns the vertex shader (full-screen quad with `v_uv`); user supplies an MSL fragment shader plus a list of uniforms packed at natural alignment into fragment-buffer slot 0. SwiftUI `MobGpuView` wraps an `MTKView` with a hash-keyed shader cache and a translucent red overlay for compile errors. iOS-only in this release; the Android GLES 3.0 backend ships in mob_new 0.3.1. +- `` tag whitelisted for both `priv/tags/ios.txt` and `priv/tags/android.txt`. + +## [0.6.3] + +### Fixed +- iOS camera sensor delivered frames in landscape-right by default — `Mob.Camera.start_frame_stream/2` was feeding 90°-rotated pixels to ML models, dropping classification accuracy enough that a jar appeared as "laptop 24%" instead of "cup 96%". `AVCaptureConnection.videoRotationAngle = 90` (iOS 17+) / `videoOrientation = .portrait` (older) is now set on both the preview layer and the data-output connection, so what the user sees and what the model sees are the same upright frame. + +## [0.6.2] + +### Added +- `Mob.Camera.start_frame_stream/2` and `stop_frame_stream/1` — push-driven per-frame delivery as `{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}`. Defaults to 640×640 `rgb_f32` for direct Nx hand-off; caller-overridable width/height/format/facing and a software `throttle_ms` gate. + +### Changed +- iOS camera now uses a single shared `AVCaptureSession` for preview and frame stream. The previous two-session design silently dropped frames because iOS allows only one active session per physical camera. + +## [0.6.1] and earlier + +Earlier releases predate this changelog; consult the [tag list](https://github.com/genericjam/mob/tags) and the per-tag commit messages for history. diff --git a/mix.exs b/mix.exs index 488067bb..3a3bc84e 100644 --- a/mix.exs +++ b/mix.exs @@ -63,9 +63,10 @@ defmodule Mob.MixProject do main: "readme", logo: "assets/logo/logo_full_color.png", source_url: "https://github.com/genericjam/mob", - source_url_pattern: "https://github.com/genericjam/mob/blob/main/%{path}#L%{line}", + source_url_pattern: "https://github.com/genericjam/mob/blob/master/%{path}#L%{line}", extras: [ "README.md": [title: "Mob"], + "CHANGELOG.md": [title: "Changelog"], "guides/why_beam.md": [title: "Why the BEAM?"], "guides/getting_started.md": [title: "Getting Started"], "guides/architecture.md": [title: "Architecture & Prior Art"], @@ -148,7 +149,7 @@ defmodule Mob.MixProject do lib src priv android ios assets mix.exs mix.lock - README.md LICENSE + README.md CHANGELOG.md LICENSE ) ] end From e237b7c45a06a45d61eb5b9800fa962915836e36 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 00:11:59 -0600 Subject: [PATCH 076/254] 0.6.5 --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa31eb73..2e7cb28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- -## [Unreleased] +## [0.6.5] ### Fixed - HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `` glyph next to a heading now opens the actual source file in the GitHub repo. diff --git a/mix.exs b/mix.exs index 3a3bc84e..d83ae2cf 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.4", + version: "0.6.5", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 0f1997a8817c3b551b516246aef815874c858159 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 00:33:28 -0600 Subject: [PATCH 077/254] release.yml: add hex_publish job gated on HEX_API_KEY secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second job after github_release. Runs `mix hex.publish --yes` (ships package + docs in one call) when the HEX_API_KEY repo secret is present; emits a GitHub Actions notice and skips cleanly when it's not. Gating pattern: HEX_API_KEY exposed at job-env level (which can read secrets), then each step uses `if: env.HEX_API_KEY != ''`. This is the only way to make secret presence a soft skip in Actions — `if:` can't reference `secrets.*` directly. If a version is already on Hex when the workflow runs (e.g. a manual `mix hex.publish` happened first), hex.publish fails loudly — that's the right signal; not silently skipped. --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a262ad4..10543f46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,3 +57,42 @@ jobs: # last tag. body_path: ${{ steps.changelog.outputs.has_body == 'true' && '/tmp/release-body.md' || '' }} generate_release_notes: ${{ steps.changelog.outputs.has_body != 'true' }} + + hex_publish: + name: Publish to Hex + needs: github_release + runs-on: ubuntu-latest + env: + # Reads the repo secret. If it's unset (or scoped to a different + # event), `if: env.HEX_API_KEY != ''` below skips the publish step + # cleanly instead of failing with an auth error. + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + if: env.HEX_API_KEY != '' + with: + elixir-version: '1.19' + otp-version: '28' + + - name: Fetch deps + if: env.HEX_API_KEY != '' + run: mix deps.get + + - name: mix hex.publish + if: env.HEX_API_KEY != '' + # `--yes` skips the interactive confirm. `mix hex.publish` (no + # subcommand) ships both the package archive AND docs in one + # call, which matches what most maintainers want per release. + # If a version was already published manually this will fail + # loudly — that's the right signal, not a silent skip. + run: mix hex.publish --yes + + - name: Skip notice (no HEX_API_KEY configured) + if: env.HEX_API_KEY == '' + run: | + echo "::notice::HEX_API_KEY secret is not set on this repository." + echo "::notice::Skipping Hex publish. Add the secret at" + echo "::notice::https://github.com/${{ github.repository }}/settings/secrets/actions" + echo "::notice::and re-tag (or re-run this workflow) to publish." From 9276e84eca6f0aa52a67590d1fef12a28b86fef0 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 01:02:58 -0600 Subject: [PATCH 078/254] =?UTF-8?q?ci:=20unblock=20test.yml=20=E2=80=94=20?= =?UTF-8?q?credo=20+=20mix=5Faudit=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing credo warnings (carried for a while; revealed by the new --strict gate) and one mix_audit invocation bug. Credo: * lib/mob/screen.ex moduledoc was a one-line restatement of the module name ("The behaviour and process wrapper for a Mob screen."). Rewrote to explain why each screen is its own supervised GenServer — isolation, crash recovery, BEAM-native concurrency tools work without extra scaffolding. * lib/mob/test.ex:953 `rescue _ -> {:error, :parse_error}` was swallowing every exception when parsing the iOS accessibility tree, including unrelated bugs. Narrowed to the concrete decode/extraction failures we can predict (KeyError, ArgumentError, MatchError, FunctionClauseError, Protocol.UndefinedError). A real bug inside the mapper now raises instead of being silently downgraded to :parse_error. * lib/mob/device.ex:236 `rescue _ -> {:error, :nif_not_loaded}` same shape — narrowed to UndefinedFunctionError + ErlangError, which are the only two ways `:mob_nif.device_set_dispatcher` can fail when the NIF isn't present. Anything else really is a bug. mix_audit: * Added `:mix_audit ~> 2.1, only: [:dev, :test], runtime: false` as a project dep (replacing the `mix archive.install hex mix_audit --force` step in CI). The archive form omits yaml_elixir from the load path, so the advisory parser crashed with "YamlElixir.read_from_file/1 is undefined". * Workflow change: `mix do app.start + deps.audit` (not just `mix deps.audit`). Even with mix_audit as a project dep, `mix deps.audit` alone fails the same way — mix_audit doesn't `Application.ensure_all_started(:yaml_elixir)`. Prefixing with `app.start` starts the host app and pulls yaml_elixir in through the runtime tree. Documented inline in mix.exs + test.yml so future agents don't get to rediscover this. mix_audit currently surfaces one real moderate-severity finding: decimal 2.4.0 → GHSA-rhv4-8758-jx7v (unbounded-exponent DoS in `Decimal.new`, patched in 3.0.0). Tracked but not blocked — the audit step is `continue-on-error: true` while we triage. Worth a follow-up to bump decimal once the upstream dep tree allows. --- .github/workflows/test.yml | 14 ++++++-------- lib/mob/device.ex | 7 ++++++- lib/mob/screen.ex | 16 +++++++++++----- lib/mob/test.ex | 8 +++++++- mix.exs | 8 ++++++++ mix.lock | 3 +++ 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03ec1fd8..4b735e05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,12 +110,10 @@ jobs: - run: mix deps.get - run: mix deps.compile - # mix_audit scans mix.lock against the Erlef advisory feed. Lightweight - # entry-level scan; the richer multi-layer scan lives in mob_dev's - # `mix mob.security_scan` (we don't depend on mob_dev here to avoid a - # dev-only dependency cycle). - - name: Install mix_audit - run: mix archive.install hex mix_audit --force - + # mix_audit scans mix.lock against the Erlef advisory feed. The + # `app.start +` prefix is load-bearing: plain `mix deps.audit` fails + # with `YamlElixir.read_from_file/1 is undefined` because mix_audit + # doesn't ensure_all_started yaml_elixir on its own; app.start + # starts the host app and yaml_elixir gets pulled in transitively. - name: Audit deps - run: mix deps.audit + run: mix do app.start + deps.audit diff --git a/lib/mob/device.ex b/lib/mob/device.ex index 7711dec8..831f3399 100644 --- a/lib/mob/device.ex +++ b/lib/mob/device.ex @@ -233,7 +233,12 @@ defmodule Mob.Device do :mob_nif.device_set_dispatcher(self()) :ok rescue - _ -> {:error, :nif_not_loaded} + # Tolerate the two failure modes that mean "the NIF isn't here": + # UndefinedFunctionError when the stub itself is unloadable, and + # ErlangError when the stub is loaded but device_set_dispatcher + # raises (e.g. because :erlang.load_nif/2 hasn't run yet on a + # host-mode build). Anything else really is a bug worth crashing on. + _ in [UndefinedFunctionError, ErlangError] -> {:error, :nif_not_loaded} end end diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 1bd138c7..853f18de 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -1,10 +1,16 @@ defmodule Mob.Screen do @moduledoc """ - The behaviour and process wrapper for a Mob screen. - - A screen is a supervised GenServer. Its state is a `Mob.Socket`. Lifecycle - callbacks (`mount`, `render`, `handle_event`, `handle_info`, `terminate`) map - directly to the GenServer lifecycle. + Behaviour and GenServer wrapper for a Mob screen. + + Each screen runs as a supervised GenServer whose state is a `Mob.Socket`. + Putting one process per screen — instead of one big process for the whole + app — gives you isolation: a buggy `handle_event` crashes its own screen + and the supervisor restarts it without taking down navigation, audio, + background services, or the BEAM itself. Lifecycle callbacks (`mount`, + `render`, `handle_event`, `handle_info`, `terminate`) map directly to the + GenServer lifecycle, so the BEAM's existing concurrency tools (selective + receive, monitors, hot code push) work on screens without any Mob-specific + scaffolding. ## Usage diff --git a/lib/mob/test.ex b/lib/mob/test.ex index fb7bced6..dea8587a 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -951,7 +951,13 @@ defmodule Mob.Test do {:ok, elements} rescue - _ -> {:error, :parse_error} + # The iOS accessibility-tree payload is opaque JSON whose shape + # has historically drifted across iOS versions. Narrow to the + # concrete decode/extraction failures we can predict, so a real + # bug (e.g. an arithmetic error inside the mapper) still raises + # instead of getting silently downgraded to :parse_error. + _ in [KeyError, ArgumentError, MatchError, FunctionClauseError, Protocol.UndefinedError] -> + {:error, :parse_error} end {reason, _code} -> diff --git a/mix.exs b/mix.exs index d83ae2cf..918247eb 100644 --- a/mix.exs +++ b/mix.exs @@ -167,6 +167,14 @@ defmodule Mob.MixProject do # Wired in via .credo.exs as `{ExSlop, []}` in the enabled list. {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, {:erlfmt, "~> 1.8", only: :dev, runtime: false}, + # mix_audit — CVE scan over mix.lock against the Erlef advisory feed. + # Invocation note: `mix deps.audit` alone fails with + # `YamlElixir.read_from_file/1 is undefined` because mix_audit doesn't + # ensure_all_started its yaml_elixir transitive dep before parsing + # the advisory files. CI works around this with `mix do app.start + + # deps.audit` (the app.start prefix starts the host app, which + # transitively starts yaml_elixir via the runtime tree). + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, # Known Elixir 1.20-rc.4 dep warning (cosmetic, dev-only): # lib/mix_unused/filter.ex:61 — `_.._ inside match is deprecated`. # No upstream fix shipped yet (0.4.1 is latest, from 2024). Bump diff --git a/mix.lock b/mix.lock index 15ca4e79..5c5039e2 100644 --- a/mix.lock +++ b/mix.lock @@ -20,7 +20,10 @@ "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_unused": {:hex, :mix_unused, "0.4.1", "9f8d759a300a79d2077d6baf617f3a5af6935d50b0f113c09295b265afc3e411", [:mix], [{:libgraph, ">= 0.0.0", [hex: :libgraph, repo: "hexpm", optional: false]}], "hexpm", "fa21f688a88e0710e3d96ac1c8e5a6181aea8a75c8a4214f0edcfeb069b831a3"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, } From b8639648cda07fca83d72cea3b5229f951f19d34 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 01:13:30 -0600 Subject: [PATCH 079/254] ci: run erlfmt under MIX_ENV=dev (the dep's only: :dev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test job runs MIX_ENV=test, so erlfmt ( in mix.exs) isn't in the dep tree there and hint: Mob.assign/3 is unused lib/mob.ex:43 hint: Mob.assign/2 is unused lib/mob.ex:44 hint: Mob.Alert.alert/2 is unused lib/mob/alert.ex:62 hint: Mob.Alert.action_sheet/2 is unused lib/mob/alert.ex:78 hint: Mob.Alert.toast/3 is unused lib/mob/alert.ex:91 hint: Mob.Alert.encode_buttons/1 should be private (is not used outside defining module) lib/mob/alert.ex:104 hint: Mob.Alert.encode_buttons/1 is unused lib/mob/alert.ex:104 hint: Mob.App.__using__/1 is unused lib/mob/app.ex:50 hint: Mob.App.stack/2 is unused lib/mob/app.ex:128 hint: Mob.App.tab_bar/1 is unused lib/mob/app.ex:149 hint: Mob.App.drawer/1 is unused lib/mob/app.ex:160 hint: Mob.Audio.start_recording/2 is unused lib/mob/audio.ex:36 hint: Mob.Audio.recording_opts/1 should be private (is not used outside defining module) lib/mob/audio.ex:49 hint: Mob.Audio.recording_opts/1 is unused lib/mob/audio.ex:49 hint: Mob.Audio.stop_recording/1 is unused lib/mob/audio.ex:58 hint: Mob.Audio.play/3 is unused lib/mob/audio.ex:68 hint: Mob.Audio.play_opts/1 should be private (is not used outside defining module) lib/mob/audio.ex:85 hint: Mob.Audio.play_opts/1 is unused lib/mob/audio.ex:85 hint: Mob.Audio.stop_playback/1 is unused lib/mob/audio.ex:94 hint: Mob.Audio.set_volume/2 is unused lib/mob/audio.ex:101 hint: Mob.Background.keep_alive/0 is unused lib/mob/background.ex:117 hint: Mob.Background.stop/0 is unused lib/mob/background.ex:126 hint: Mob.Biometric.authenticate/2 is unused lib/mob/biometric.ex:23 hint: Mob.Bt.list_paired/1 is unused lib/mob/bt.ex:83 hint: Mob.Bt.start_discovery/1 is unused lib/mob/bt.ex:94 hint: Mob.Bt.cancel_discovery/1 is unused lib/mob/bt.ex:107 hint: Mob.Bt.pair/3 is unused lib/mob/bt.ex:116 hint: Mob.Bt.unpair/2 is unused lib/mob/bt.ex:136 hint: Mob.Bt.disconnect/2 is unused lib/mob/bt.ex:148 hint: Mob.Bt.encode_pair/2 should be private (is not used outside defining module) lib/mob/bt.ex:171 hint: Mob.Bt.encode_pair/2 is unused lib/mob/bt.ex:171 hint: Mob.Bt.encode_device/1 is unused lib/mob/bt.ex:179 hint: Mob.Bt.Hfp.connect/2 is unused lib/mob/bt/hfp.ex:64 hint: Mob.Bt.Hfp.subscribe_vendor_at/3 is unused lib/mob/bt/hfp.ex:79 hint: Mob.Bt.Hfp.encode_vendor_at_opts/1 should be private (is not used outside defining module) lib/mob/bt/hfp.ex:112 hint: Mob.Bt.Hfp.encode_vendor_at_opts/1 is unused lib/mob/bt/hfp.ex:112 hint: Mob.Bt.Hfp.send_vendor_at/4 is unused lib/mob/bt/hfp.ex:122 hint: Mob.Bt.Hfp.start_sco/2 is unused lib/mob/bt/hfp.ex:135 hint: Mob.Bt.Hfp.stop_sco/2 is unused lib/mob/bt/hfp.ex:150 hint: Mob.Bt.Hfp.send_audio/3 is unused lib/mob/bt/hfp.ex:161 hint: Mob.Bt.Hid.connect/2 is unused lib/mob/bt/hid.ex:58 hint: Mob.Bt.Hid.subscribe_raw/2 is unused lib/mob/bt/hid.ex:73 hint: Mob.Bt.Spp.connect/3 is unused lib/mob/bt/spp.ex:48 hint: Mob.Bt.Spp.encode_connect/2 should be private (is not used outside defining module) lib/mob/bt/spp.ex:67 hint: Mob.Bt.Spp.encode_connect/2 is unused lib/mob/bt/spp.ex:67 hint: Mob.Bt.Spp.write/3 is unused lib/mob/bt/spp.ex:79 hint: Mob.Camera.capture_photo/2 is unused lib/mob/camera.ex:38 hint: Mob.Camera.capture_video/2 is unused lib/mob/camera.ex:51 hint: Mob.Camera.start_preview/2 is unused lib/mob/camera.ex:64 hint: Mob.Camera.stop_preview/1 is unused lib/mob/camera.ex:78 hint: Mob.Camera.start_frame_stream/2 is unused lib/mob/camera.ex:85 hint: Mob.Camera.frame_stream_opts/1 should be private (is not used outside defining module) lib/mob/camera.ex:138 hint: Mob.Camera.frame_stream_opts/1 is unused lib/mob/camera.ex:138 hint: Mob.Camera.stop_frame_stream/1 is unused lib/mob/camera.ex:154 hint: Mob.Canvas.line/5 is unused lib/mob/canvas.ex:60 hint: Mob.Canvas.circle/4 is unused lib/mob/canvas.ex:71 hint: Mob.Canvas.ellipse/5 is unused lib/mob/canvas.ex:83 hint: Mob.Canvas.arc/6 is unused lib/mob/canvas.ex:102 hint: Mob.Canvas.rect/5 is unused lib/mob/canvas.ex:124 hint: Mob.Canvas.path/2 is unused lib/mob/canvas.ex:136 hint: Mob.Canvas.text/4 is unused lib/mob/canvas.ex:156 hint: Mob.Canvas.image/6 is unused lib/mob/canvas.ex:177 hint: Mob.Clipboard.put/2 is unused lib/mob/clipboard.ex:25 hint: Mob.Clipboard.get/1 is unused lib/mob/clipboard.ex:34 hint: Mob.Component.__using__/1 is unused lib/mob/component.ex:86 hint: Mob.ComponentRegistry.child_spec/1 is unused lib/mob/component_registry.ex:8 hint: Mob.ComponentRegistry.start_link/1 is unused lib/mob/component_registry.ex:13 hint: Mob.ComponentServer.child_spec/1 is unused lib/mob/component_server.ex:6 hint: Mob.ComponentServer.dispatch/3 is unused lib/mob/component_server.ex:26 hint: Mob.Device.child_spec/1 is unused lib/mob/device.ex:46 hint: Mob.Device.start_link/1 is unused lib/mob/device.ex:70 hint: Mob.Device.unsubscribe/0 is unused lib/mob/device.ex:95 hint: Mob.Device.categories/0 is unused lib/mob/device.ex:101 hint: Mob.Device.battery_level/0 is unused lib/mob/device.ex:107 hint: Mob.Device.battery_state/0 is unused lib/mob/device.ex:114 hint: Mob.Device.thermal_state/0 is unused lib/mob/device.ex:121 hint: Mob.Device.low_power_mode?/0 is unused lib/mob/device.ex:125 hint: Mob.Device.foreground?/0 is unused lib/mob/device.ex:129 hint: Mob.Device.os_version/0 is unused lib/mob/device.ex:133 hint: Mob.Device.model/0 is unused lib/mob/device.ex:137 hint: Mob.Device.open_url/1 is unused lib/mob/device.ex:141 hint: Mob.Device.category_for/1 should be private (is not used outside defining module) lib/mob/device.ex:293 hint: Mob.Device.Android.child_spec/1 is unused lib/mob/device/android.ex:32 hint: Mob.Device.Android.start_link/1 is unused lib/mob/device/android.ex:35 hint: Mob.Device.Android.subscribe/0 is unused lib/mob/device/android.ex:39 hint: Mob.Device.Android.unsubscribe/0 is unused lib/mob/device/android.ex:45 hint: Mob.Device.IOS.child_spec/1 is unused lib/mob/device/ios.ex:36 hint: Mob.Device.IOS.start_link/1 is unused lib/mob/device/ios.ex:39 hint: Mob.Device.IOS.subscribe/0 is unused lib/mob/device/ios.ex:43 hint: Mob.Device.IOS.unsubscribe/0 is unused lib/mob/device/ios.ex:49 hint: Mob.Device.IOS.raw_thermal_state/0 is unused lib/mob/device/ios.ex:55 hint: Mob.Diag.verify_loaded_modules/0 is unused lib/mob/diag.ex:26 hint: Mob.Diag.loaded_snapshot/0 is unused lib/mob/diag.ex:93 hint: Mob.Diag.mfa_trace/1 is unused lib/mob/diag.ex:136 hint: Mob.Dist.ensure_started/1 is unused lib/mob/dist.ex:32 hint: Mob.Dist.release_mode?/0 should be private (is not used outside defining module) lib/mob/dist.ex:104 hint: Mob.Dist.release_mode?/0 is unused lib/mob/dist.ex:104 hint: Mob.Dist.apply_suffix/2 should be private (is not used outside defining module) lib/mob/dist.ex:108 hint: Mob.Dist.apply_suffix/2 is unused lib/mob/dist.ex:108 hint: Mob.Dist.env_dist_port/0 should be private (is not used outside defining module) lib/mob/dist.ex:126 hint: Mob.Dist.env_dist_port/0 is unused lib/mob/dist.ex:126 hint: Mob.Dist.stop/0 is unused lib/mob/dist.ex:144 hint: Mob.DNS.resolve/1 should be private (is not used outside defining module) lib/mob/dns.ex:113 hint: Mob.DNS.resolve/1 is unused lib/mob/dns.ex:113 hint: Mob.DNS.preresolve/1 is unused lib/mob/dns.ex:136 hint: Mob.DNS.configure_pure_beam/1 is unused lib/mob/dns.ex:153 hint: Mob.DNS.resolved?/1 should be private (is not used outside defining module) lib/mob/dns.ex:226 hint: Mob.DNS.resolved?/1 is unused lib/mob/dns.ex:226 hint: Mob.DNS.resolved?/1 is called only recursively lib/mob/dns.ex:226 hint: Mob.Event.emit/5 is unused lib/mob/event.ex:29 hint: Mob.Event.dispatch/4 should be private (is not used outside defining module) lib/mob/event.ex:55 hint: Mob.Event.dispatch/4 is unused lib/mob/event.ex:55 hint: Mob.Event.is_event?/1 is unused lib/mob/event.ex:71 hint: Mob.Event.match_address?/2 is unused lib/mob/event.ex:84 hint: Mob.Event.send_test/7 is unused lib/mob/event.ex:106 hint: Mob.Event.Address.same_widget?/2 is unused lib/mob/event/address.ex:116 hint: Mob.Event.Address.current?/2 is unused lib/mob/event/address.ex:129 hint: Mob.Event.Address.with_render_id/2 is unused lib/mob/event/address.ex:140 hint: String.Chars.Mob.Event.Address.__impl__/1 is unused lib/mob/event/address.ex:188 hint: Inspect.Mob.Event.Address.__impl__/1 is unused lib/mob/event/address.ex:192 hint: Mob.Event.Bridge.legacy_to_canonical/3 should be private (is not used outside defining module) lib/mob/event/bridge.ex:47 hint: Mob.Event.Bridge.legacy_to_canonical/3 is unused lib/mob/event/bridge.ex:47 hint: Mob.Event.Bridge.legacy_to_canonical!/3 is unused lib/mob/event/bridge.ex:212 hint: Mob.Event.Component.__using__/1 is unused lib/mob/event/component.ex:78 hint: Mob.Event.Target.resolve/2 is unused lib/mob/event/target.ex:48 hint: Mob.Event.Target.classify/1 is unused lib/mob/event/target.ex:111 hint: Mob.Event.Throttle.default_for/1 should be private (is not used outside defining module) lib/mob/event/throttle.ex:62 hint: Mob.Event.Throttle.default?/2 is unused lib/mob/event/throttle.ex:130 hint: Mob.Event.Trace.start/0 should be private (is not used outside defining module) lib/mob/event/trace.ex:42 hint: Mob.Event.Trace.start/0 is unused lib/mob/event/trace.ex:42 hint: Mob.Event.Trace.stop/0 is unused lib/mob/event/trace.ex:58 hint: Mob.Event.Trace.subscribe/1 is unused lib/mob/event/trace.ex:71 hint: Mob.Event.Trace.unsubscribe/0 is unused lib/mob/event/trace.ex:86 hint: Mob.Event.Trace.broadcast/3 is unused lib/mob/event/trace.ex:97 hint: Mob.Files.pick/2 is unused lib/mob/files.ex:21 hint: Mob.Haptic.trigger/2 is unused lib/mob/haptic.ex:30 hint: Mob.List.put_renderer/3 is unused lib/mob/list.ex:56 hint: Mob.List.default_renderer/1 should be private (is not used outside defining module) lib/mob/list.ex:71 hint: Mob.LiveView.__using__/1 is unused lib/mob/live_view.ex:189 hint: Mob.LiveView.local_url/1 is unused lib/mob/live_view.ex:201 hint: Mob.Location.get_once/1 is unused lib/mob/location.ex:33 hint: Mob.Location.start/2 is unused lib/mob/location.ex:42 hint: Mob.Location.stop/1 is unused lib/mob/location.ex:57 hint: Mob.Motion.start/2 is unused lib/mob/motion.ex:22 hint: Mob.Motion.stop/1 is unused lib/mob/motion.ex:40 hint: Mob.NativeLogger.install/1 is unused lib/mob/native_logger.ex:35 hint: Mob.NativeLogger.log/2 is unused lib/mob/native_logger.ex:60 hint: Mob.NativeLogger.format_msg/2 should be private (is not used outside defining module) lib/mob/native_logger.ex:69 hint: Mob.NativeLogger.format_msg/2 is unused lib/mob/native_logger.ex:69 hint: Mob.NativeLogger.level_to_nif/1 should be private (is not used outside defining module) lib/mob/native_logger.ex:80 hint: Mob.NativeLogger.level_to_nif/1 is unused lib/mob/native_logger.ex:80 hint: Mob.Nav.Registry.child_spec/1 is unused lib/mob/nav/registry.ex:13 hint: Mob.Nav.Registry.start_link/1 is unused lib/mob/nav/registry.ex:17 hint: Mob.Nav.Registry.register/2 is unused lib/mob/nav/registry.ex:41 hint: Mob.Notify.schedule/2 is unused lib/mob/notify.ex:43 hint: Mob.Notify.cancel/2 is unused lib/mob/notify.ex:83 hint: Mob.Notify.register_push/1 is unused lib/mob/notify.ex:93 hint: Mob.Permissions.request/2 is unused lib/mob/permissions.ex:28 hint: Mob.Photos.pick/2 is unused lib/mob/photos.ex:27 hint: Mob.Registry.child_spec/1 is unused lib/mob/registry.ex:25 hint: Mob.Registry.start_link/1 is unused lib/mob/registry.ex:36 hint: Mob.Registry.register/3 is unused lib/mob/registry.ex:54 hint: Mob.Registry.lookup/3 is unused lib/mob/registry.ex:74 hint: Mob.Registry.all/1 is unused lib/mob/registry.ex:90 hint: Mob.Renderer.colors/0 is unused lib/mob/renderer.ex:244 hint: Mob.Renderer.text_sizes/0 is unused lib/mob/renderer.ex:248 hint: Mob.Scanner.scan/2 is unused lib/mob/scanner.ex:34 hint: Mob.Screen.__using__/1 is unused lib/mob/screen.ex:99 hint: Mob.Screen.child_spec/1 is unused lib/mob/screen.ex:127 hint: Mob.Screen.start_link/3 is unused lib/mob/screen.ex:129 hint: Mob.Screen.get_current_module/1 is unused lib/mob/screen.ex:139 hint: Mob.Screen.get_nav_history/1 is unused lib/mob/screen.ex:148 hint: Mob.Screen.start_root/3 is unused lib/mob/screen.ex:157 hint: Mob.Screen.dispatch/3 is unused lib/mob/screen.ex:170 hint: Mob.Screen.get_socket/1 is unused lib/mob/screen.ex:179 hint: Mob.ScreenState.delete/2 is unused lib/mob/screen_state.ex:86 hint: Mob.Share.text/2 is unused lib/mob/share.ex:17 hint: Mob.Sigil.brace_content/2 is unused lib/mob/sigil.ex:170 hint: Mob.Sigil.node/2 is unused lib/mob/sigil.ex:184 hint: Mob.Sigil.parse_template/2 is unused lib/mob/sigil.ex:192 hint: Mob.Sigil.sigil_MOB/2 is unused lib/mob/sigil.ex:202 hint: Mob.Sigil.wrap_child/1 is unused lib/mob/sigil.ex:307 hint: Mob.Socket.push_screen/3 is unused lib/mob/socket.ex:101 hint: Mob.Socket.pop_screen/1 is unused lib/mob/socket.ex:116 hint: Mob.Socket.pop_to/2 is unused lib/mob/socket.ex:126 hint: Mob.Socket.pop_to_root/1 is unused lib/mob/socket.ex:136 hint: Mob.Socket.reset_to/3 is unused lib/mob/socket.ex:144 hint: Mob.Socket.switch_tab/2 is unused lib/mob/socket.ex:154 hint: Mob.State.child_spec/1 is unused lib/mob/state.ex:39 hint: Mob.State.start_link/1 is unused lib/mob/state.ex:45 hint: Mob.State.get/2 is unused lib/mob/state.ex:51 hint: Mob.State.put/2 is unused lib/mob/state.ex:75 hint: Mob.State.delete/1 is unused lib/mob/state.ex:95 hint: Mob.State.match/1 is unused lib/mob/state.ex:109 hint: Mob.Storage.dir/1 should be private (is not used outside defining module) lib/mob/storage.ex:31 hint: Mob.Storage.dir/1 is unused lib/mob/storage.ex:31 hint: Mob.Storage.list/1 should be private (is not used outside defining module) lib/mob/storage.ex:37 hint: Mob.Storage.list/1 is unused lib/mob/storage.ex:37 hint: Mob.Storage.list/1 is called only recursively lib/mob/storage.ex:37 hint: Mob.Storage.stat/1 is unused lib/mob/storage.ex:52 hint: Mob.Storage.delete/1 is unused lib/mob/storage.ex:75 hint: Mob.Storage.copy/2 should be private (is not used outside defining module) lib/mob/storage.ex:79 hint: Mob.Storage.copy/2 is unused lib/mob/storage.ex:79 hint: Mob.Storage.copy/2 is called only recursively lib/mob/storage.ex:79 hint: Mob.Storage.move/2 should be private (is not used outside defining module) lib/mob/storage.ex:96 hint: Mob.Storage.move/2 is unused lib/mob/storage.ex:96 hint: Mob.Storage.move/2 is called only recursively lib/mob/storage.ex:96 hint: Mob.Storage.read/1 is unused lib/mob/storage.ex:112 hint: Mob.Storage.write/2 is unused lib/mob/storage.ex:116 hint: Mob.Storage.extension/1 is unused lib/mob/storage.ex:126 hint: Mob.Storage.Android.external_files_dir/1 is unused lib/mob/storage/android.ex:31 hint: Mob.Storage.Android.save_to_media_store/3 is unused lib/mob/storage/android.ex:44 hint: Mob.Storage.Apple.dir/1 is unused lib/mob/storage/apple.ex:37 hint: Mob.Storage.Apple.save_to_photo_library/2 is unused lib/mob/storage/apple.ex:50 hint: Mob.Style.merge/2 is unused lib/mob/style.ex:44 hint: Mob.Style.put/3 is unused lib/mob/style.ex:50 hint: Mob.Theme.default/0 should be private (is not used outside defining module) lib/mob/theme.ex:130 hint: Mob.UI.text/1 should be private (is not used outside defining module) lib/mob/ui.ex:23 hint: Mob.UI.text/1 is unused lib/mob/ui.ex:23 hint: Mob.UI.text/1 is called only recursively lib/mob/ui.ex:23 hint: Mob.UI.webview/1 should be private (is not used outside defining module) lib/mob/ui.ex:51 hint: Mob.UI.webview/1 is unused lib/mob/ui.ex:51 hint: Mob.UI.webview/1 is called only recursively lib/mob/ui.ex:51 hint: Mob.UI.camera_preview/1 should be private (is not used outside defining module) lib/mob/ui.ex:82 hint: Mob.UI.camera_preview/1 is unused lib/mob/ui.ex:82 hint: Mob.UI.camera_preview/1 is called only recursively lib/mob/ui.ex:82 hint: Mob.UI.native_view/2 should be private (is not used outside defining module) lib/mob/ui.ex:104 hint: Mob.UI.native_view/2 is unused lib/mob/ui.ex:104 hint: Mob.UI.native_view/2 is called only recursively lib/mob/ui.ex:104 hint: Mob.UI.canvas/1 should be private (is not used outside defining module) lib/mob/ui.ex:126 hint: Mob.UI.canvas/1 is unused lib/mob/ui.ex:126 hint: Mob.UI.canvas/1 is called only recursively lib/mob/ui.ex:126 hint: Mob.UI.gpu_view/1 should be private (is not used outside defining module) lib/mob/ui.ex:168 hint: Mob.UI.gpu_view/1 is unused lib/mob/ui.ex:168 hint: Mob.UI.gpu_view/1 is called only recursively lib/mob/ui.ex:168 hint: Mob.VendorUsb.list_devices/2 is unused lib/mob/vendor_usb.ex:148 hint: Mob.VendorUsb.request_permission/2 is unused lib/mob/vendor_usb.ex:175 hint: Mob.VendorUsb.open/3 is unused lib/mob/vendor_usb.ex:194 hint: Mob.VendorUsb.bulk_write/4 is unused lib/mob/vendor_usb.ex:223 hint: Mob.VendorUsb.start_reading/3 is unused lib/mob/vendor_usb.ex:255 hint: Mob.VendorUsb.stop_reading/2 is unused lib/mob/vendor_usb.ex:275 hint: Mob.VendorUsb.close/2 is unused lib/mob/vendor_usb.ex:282 hint: Mob.WebView.eval_js/2 is unused lib/mob/webview.ex:28 hint: Mob.WebView.post_message/2 is unused lib/mob/webview.ex:38 erlfmt: 1 file(s) checked, all formatted failed with 'requires the :erlfmt dep'. Locally reproducible with `MIX_ENV=test mix erlfmt --check src/`. Two ways to fix: broaden the dep's only: to include :test, or override MIX_ENV per-step. Picked the second — keeps the test env minimal (we don't want erlfmt sitting in test's load path for no reason). The step now sets env: MIX_ENV: dev and runs mix deps.get to populate _build/dev (deps cache is shared between env subdirs) before the erlfmt check itself. Cost: one extra deps.get + erlfmt compile under :dev per workflow run, ~5-10s. Acceptable for the boundary clarity. --- .github/workflows/test.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b735e05..15e80dbc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,14 @@ jobs: run: mix credo --strict - name: erlfmt check (src/) - run: mix erlfmt --check src/ + # erlfmt is `only: :dev` in mix.exs, so the test-env dep tree + # doesn't include it. Override MIX_ENV for just this step instead + # of broadening the dep's `only:` (keeps the test env minimal). + env: + MIX_ENV: dev + run: | + mix deps.get + mix erlfmt --check src/ - name: Tests run: mix test From bba3b5d7e99c30a74c3395e94d9c6bb8877eb32b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 01:53:11 -0600 Subject: [PATCH 080/254] =?UTF-8?q?ci:=20remove=20onboarding.yml=20?= =?UTF-8?q?=E2=80=94=20running=20these=20locally=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Onboarding Integration Tests workflow has been failing nightly on cron runs for a while (matrix configures Nix / mise / asdf against an Android SDK + iOS simulator setup that's a moving target on macos-15 runners — the failures rarely point at real mob bugs). Per CLAUDE.md / PLAN.md the onboarding suite is excluded from `mix test` by default and is meant to be invoked deliberately (`mix test --only generator` etc.). Keeping it as a CI job at this point produces noise rather than signal — we run the generator/failure_modes/with-devices tests on a maintainer laptop on demand instead. The test source (test/onboarding/) and the docs that describe how to run it stay; only the GitHub Actions workflow is removed. --- .github/workflows/onboarding.yml | 247 ------------------------------- 1 file changed, 247 deletions(-) delete mode 100644 .github/workflows/onboarding.yml diff --git a/.github/workflows/onboarding.yml b/.github/workflows/onboarding.yml deleted file mode 100644 index d59336d1..00000000 --- a/.github/workflows/onboarding.yml +++ /dev/null @@ -1,247 +0,0 @@ -name: Onboarding Integration Tests - -on: - push: - branches: [main] - paths: - - 'lib/**' - - 'priv/templates/**' - - 'test/onboarding/**' - - '.github/workflows/onboarding.yml' - pull_request: - paths: - - 'lib/**' - - 'priv/templates/**' - - 'test/onboarding/**' - schedule: - # Nightly at 06:00 UTC — catches regressions from upstream changes - - cron: '0 6 * * *' - workflow_dispatch: - inputs: - scope: - description: 'Test scope (generator | pre_device | all)' - required: false - default: 'generator' - -concurrency: - group: onboarding-${{ github.ref }} - cancel-in-progress: true - -jobs: - # ── Fast gate: generator + pre-device (no simulator required) ──────────────── - pre-device: - name: "Pre-device (${{ matrix.env }} / Elixir ${{ matrix.elixir }})" - strategy: - fail-fast: false - matrix: - include: - # Primary: Nix — highest-risk environment based on user reports - - run: A - env: nix - elixir: "1.18" - otp: "27" - priority: critical - - # Latest toolchain via mise - - run: B - env: mise - elixir: "1.19" - otp: "28" - priority: critical - - # Minimum versions via mise - - run: C - env: mise - elixir: "1.18" - otp: "27" - priority: standard - - # asdf - - run: D - env: asdf - elixir: "1.19" - otp: "28" - priority: standard - - runs-on: macos-15 # Apple Silicon; required for arm64 Android images - - steps: - - uses: actions/checkout@v4 - - # ── Nix setup (Run A) ────────────────────────────────────────────────── - - name: Install Nix - if: matrix.env == 'nix' - uses: cachix/install-nix-action@v26 - with: - nix_path: nixpkgs=channel:nixos-24.05 - - - name: Enter Nix dev shell and verify elixir version - if: matrix.env == 'nix' - run: | - nix develop ./test/onboarding/nix#default --command elixir --version - # Ensure Nix elixir is 1.18.x - nix develop ./test/onboarding/nix#default --command \ - elixir -e 'v = System.version(); [maj, min | _] = String.split(v, "."); if String.to_integer(min) < 18, do: exit(1)' - - # ── mise setup (Runs B, C) ───────────────────────────────────────────── - - name: Install mise - if: matrix.env == 'mise' - run: | - curl https://mise.run | sh - echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - - - name: Configure mise versions - if: matrix.env == 'mise' - run: | - mise use --global elixir@${{ matrix.elixir }} - mise use --global erlang@${{ matrix.otp }} - mise install - elixir --version - - # ── asdf setup (Run D) ──────────────────────────────────────────────── - - name: Install asdf - if: matrix.env == 'asdf' - uses: asdf-vm/actions/setup@v3 - - - name: Configure asdf versions - if: matrix.env == 'asdf' - run: | - asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git || true - asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git || true - asdf install elixir ${{ matrix.elixir }} - asdf install erlang ${{ matrix.otp }} - echo "elixir ${{ matrix.elixir }}" >> ~/.tool-versions - echo "erlang ${{ matrix.otp }}" >> ~/.tool-versions - - # ── Android SDK ─────────────────────────────────────────────────────── - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android SDK components - run: | - sdkmanager "platform-tools" "emulator" "build-tools;34.0.0" - sdkmanager "system-images;android-28;google_apis;arm64-v8a" - sdkmanager "system-images;android-35;google_apis;arm64-v8a" - sdkmanager "platforms;android-34" "platforms;android-35" - - # ── Install Hex and mob archive ─────────────────────────────────────── - - name: Install Hex - run: mix local.hex --force - - - name: Cache mob archive - uses: actions/cache@v4 - with: - path: ~/.mix/archives - key: mob-archive-${{ hashFiles('mix.exs') }} - - # ── Run tests ───────────────────────────────────────────────────────── - - name: Run generator + pre-device tests - run: | - mix mob.onboarding_test --only generator --env ${{ matrix.env }} - mix mob.onboarding_test --only pre_device --env ${{ matrix.env }} - timeout-minutes: 20 - env: - MIX_ENV: test - # On Nix: ensure system curl is first in PATH to avoid SSL issues - PATH: /usr/bin:/bin:${{ env.PATH }} - - # ── Artifacts on failure ────────────────────────────────────────────── - - name: Upload failure logs - if: failure() - uses: actions/upload-artifact@v4 - with: - name: onboarding-pre-device-run-${{ matrix.run }} - path: /tmp/mob_onboarding_*/logs/ - retention-days: 7 - - # ── Full device tests: iOS + Android simulators/emulators ──────────────────── - with-devices: - name: "With devices (${{ matrix.ios }} + ${{ matrix.android }})" - needs: pre-device # Only run after the fast gate passes - if: | - github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch' && github.event.inputs.scope == 'all' || - github.ref == 'refs/heads/main' - - strategy: - fail-fast: false - matrix: - include: - # Minimum versions — most likely to reveal compatibility issues - - run: ios-min - ios: ios_min - android: android_min - - # Maximum versions — latest OS features, fresh APIs - - run: ios-max - ios: ios_max - android: android_max - - runs-on: macos-15 - - steps: - - uses: actions/checkout@v4 - - - name: Install mise + Elixir 1.19 - run: | - curl https://mise.run | sh - echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - mise use --global elixir@1.19 - mise use --global erlang@28 - mise install - - - name: Install Hex + mob archive - run: mix local.hex --force - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android system images - run: | - sdkmanager "platform-tools" "emulator" "build-tools;34.0.0" - sdkmanager "system-images;android-28;google_apis;arm64-v8a" - sdkmanager "system-images;android-35;google_apis;arm64-v8a" - sdkmanager "platforms;android-34" "platforms;android-35" - - - name: Download iOS 16 runtime (ios-min only) - if: matrix.ios == 'ios_min' - run: | - xcrun simctl runtime add "com.apple.CoreSimulator.SimRuntime.iOS-16-0" || true - timeout-minutes: 15 - - - name: Run full onboarding test (ios=${{ matrix.ios }}, android=${{ matrix.android }}) - run: mix mob.onboarding_test --all --env mise - timeout-minutes: 45 - env: - MIX_ENV: test - MOB_TEST_IOS_SLOT: ${{ matrix.ios }} - MOB_TEST_ANDROID_SLOT: ${{ matrix.android }} - - - name: Upload failure logs and workspace - if: failure() - uses: actions/upload-artifact@v4 - with: - name: onboarding-device-${{ matrix.run }} - path: | - /tmp/mob_onboarding_*/logs/ - /tmp/mob_onboarding_*/mob_failure_test/ - retention-days: 7 - - # ── Summary ─────────────────────────────────────────────────────────────────── - summary: - name: Onboarding gate - needs: [pre-device, with-devices] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check results - run: | - if [[ "${{ needs.pre-device.result }}" != "success" ]]; then - echo "::error::Pre-device tests failed" - exit 1 - fi - if [[ "${{ needs.with-devices.result }}" == "failure" ]]; then - echo "::error::Device tests failed" - exit 1 - fi - echo "All onboarding tests passed" From 59338d28f75d3e85209b94c73e80351d5df3d0e3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 02:01:08 -0600 Subject: [PATCH 081/254] =?UTF-8?q?release.yml:=20fix=20tag=20pattern=20?= =?UTF-8?q?=E2=80=94=20glob,=20not=20regex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions filter patterns are glob (minimatch), not regex. The previous tag patterns `[0-9]+.[0-9]+.[0-9]+` looked like regex but in glob `+` outside `+(pattern)` is a literal character — so the pattern only matched strings like `5+.5+.5+` and never an actual version like `0.6.5`. The release workflow has zero runs since it was added (gh api confirms: total_count = 0); none of the 0.6.2 / 0.6.3 / 0.6.4 / 0.6.5 tag pushes ever fired it. Hex never got published. Replaced with plain `*.*.*` and `*.*.*-*` which are valid glob and match the X.Y.Z[-prerelease] convention the project actually uses. Backfill plan for 0.6.5: simplest is to manually `mix hex.publish` from local one time, then the next tag (0.6.6 or whatever) goes through the now-working workflow. --- .github/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10543f46..dedec129 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,12 @@ name: release on: push: tags: - - '[0-9]+.[0-9]+.[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+-*' + # GitHub Actions filter patterns are glob (minimatch), NOT regex. + # `+` outside extglob `+(...)` is a literal `+`, not a quantifier — + # so the regex-looking `[0-9]+.[0-9]+.[0-9]+` matches strings like + # `5+.5+.5+` and never `0.6.5`. Plain `*` is the right glob. + - '*.*.*' # X.Y.Z + - '*.*.*-*' # X.Y.Z-prerelease (rc, alpha, beta, etc.) # Only one release run per tag at a time. Cancelling in-progress is # deliberately OFF — a release in flight should always finish. From 2c4c76901f5148eacb075ff14a09a3b406a99fc3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 02:11:40 -0600 Subject: [PATCH 082/254] release.yml: drive from mix.exs version, drop the manual tag step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger model change: instead of firing on tag push, the workflow now fires when mix.exs changes on master (or via Actions tab → Run workflow). The new flow is: 1. Bump `version: "X.Y.Z"` in mix.exs 2. git commit -m "X.Y.Z" 3. git push origin master …and the workflow handles tagging, GitHub Release, and Hex publish. What the job does in order: * Extract the version literal from mix.exs (grep + sed against the standard `version: "..."` shape). * Check if a git tag for that version already exists. If so, emit a notice and skip — nothing to do (re-pushes of master after a bump won't double-release). * Otherwise: tag, push the tag, extract the matching `## [X.Y.Z]` section from CHANGELOG.md, create the GitHub Release with that section as the body (falling back to GitHub's auto-generated notes if the section is missing). * If HEX_API_KEY is set, `mix hex.publish --yes`. Otherwise print a notice with the secrets URL and skip cleanly. Two trigger sources: * push: branches: [master], paths: ['mix.exs'] — the normal flow. * workflow_dispatch — re-run for a failed Hex publish without bumping again (Actions tab → release → Run workflow). Why mix.exs as the source of truth: - one less manual step per release (no `git tag`) - version-string drift between mix.exs and the tag becomes impossible (they're the same value) - the previous tag-pattern bug (regex-shaped glob never matched) is moot here — there's no tag pattern at all Tag creation needs `contents: write` and a configured git identity; both are already in place. GITHUB_TOKEN's pushed tag does NOT re-trigger workflows (recursion guard), which is what we want — no risk of an auto-tag firing another workflow run. --- .github/workflows/release.yml | 124 ++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dedec129..186d1348 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,43 +1,81 @@ name: release +# Trigger model: mix.exs is the source of truth for the version. Bump the +# `version: "X.Y.Z"` line, commit, push — the workflow detects the change, +# tags it, creates the GitHub Release, and publishes to Hex. +# +# Re-running manually (e.g. to retry a failed Hex publish without bumping +# again) is via the Actions tab's "Run workflow" button (workflow_dispatch). on: push: - tags: - # GitHub Actions filter patterns are glob (minimatch), NOT regex. - # `+` outside extglob `+(...)` is a literal `+`, not a quantifier — - # so the regex-looking `[0-9]+.[0-9]+.[0-9]+` matches strings like - # `5+.5+.5+` and never `0.6.5`. Plain `*` is the right glob. - - '*.*.*' # X.Y.Z - - '*.*.*-*' # X.Y.Z-prerelease (rc, alpha, beta, etc.) + branches: [master] + paths: ['mix.exs'] + workflow_dispatch: -# Only one release run per tag at a time. Cancelling in-progress is -# deliberately OFF — a release in flight should always finish. concurrency: group: release-${{ github.ref }} cancel-in-progress: false permissions: - contents: write # required to create a GitHub Release + contents: write # required to create the GitHub Release AND push the tag jobs: - github_release: - name: Create GitHub Release + release: + name: Release from mix.exs runs-on: ubuntu-latest + env: + # Reads the repo secret. Below steps gate on `env.HEX_API_KEY != ''` + # so a missing secret skips the Hex publish cleanly instead of + # failing with an auth error. + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} steps: - uses: actions/checkout@v4 with: - # Full history so action-gh-release can build a changelog from the - # commits between this tag and the previous one. - fetch-depth: 0 + fetch-depth: 0 # full history for changelog generation + + - name: Configure git identity (for the tag push below) + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Extract version from mix.exs + id: version + run: | + # Pull the `version: "X.Y.Z"` from the project/0 keyword list. + # Tolerates leading whitespace, any quoting style, but expects + # a literal double-quoted string (which is the Elixir-mix + # convention). + version=$(grep -E '^\s*version:\s*"' mix.exs | head -1 | sed 's/.*"\([^"]*\)".*/\1/') + if [ -z "$version" ]; then + echo "::error::Could not extract version from mix.exs" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Detected version: $version" + + - name: Check if tag already exists + id: tag_check + run: | + if git rev-parse "refs/tags/${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::Tag ${{ steps.version.outputs.version }} already exists — nothing to release. Bump mix.exs to create a new one." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi - - name: Extract CHANGELOG section for this tag + - name: Create + push tag + if: steps.tag_check.outputs.exists == 'false' + run: | + tag="${{ steps.version.outputs.version }}" + git tag "$tag" + git push origin "$tag" + + - name: Extract CHANGELOG section + if: steps.tag_check.outputs.exists == 'false' id: changelog run: | if [ -f CHANGELOG.md ]; then - # Pull the section under "## []" or "## " up to the - # next "## " heading. If nothing matches, the body falls back - # to auto-generated commit notes. - awk -v tag="${{ github.ref_name }}" ' + awk -v tag="${{ steps.version.outputs.version }}" ' $0 ~ "^## \\[" tag "\\]" || $0 ~ "^## " tag "( |$)" { in_section=1; next } in_section && /^## / { exit } in_section { print } @@ -52,51 +90,33 @@ jobs: fi - name: Create GitHub Release + if: steps.tag_check.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - # If CHANGELOG.md had a matching section, use it. Otherwise let - # the action auto-generate notes from the commit log since the - # last tag. + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} body_path: ${{ steps.changelog.outputs.has_body == 'true' && '/tmp/release-body.md' || '' }} generate_release_notes: ${{ steps.changelog.outputs.has_body != 'true' }} - hex_publish: - name: Publish to Hex - needs: github_release - runs-on: ubuntu-latest - env: - # Reads the repo secret. If it's unset (or scoped to a different - # event), `if: env.HEX_API_KEY != ''` below skips the publish step - # cleanly instead of failing with an auth error. - HEX_API_KEY: ${{ secrets.HEX_API_KEY }} - steps: - - uses: actions/checkout@v4 - - - uses: erlef/setup-beam@v1 - if: env.HEX_API_KEY != '' + - name: Set up BEAM + if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY != '' + uses: erlef/setup-beam@v1 with: elixir-version: '1.19' otp-version: '28' - - name: Fetch deps - if: env.HEX_API_KEY != '' - run: mix deps.get - - name: mix hex.publish - if: env.HEX_API_KEY != '' + if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY != '' # `--yes` skips the interactive confirm. `mix hex.publish` (no - # subcommand) ships both the package archive AND docs in one - # call, which matches what most maintainers want per release. - # If a version was already published manually this will fail - # loudly — that's the right signal, not a silent skip. - run: mix hex.publish --yes + # subcommand) ships both the package archive AND docs in one call. + run: | + mix deps.get + mix hex.publish --yes - - name: Skip notice (no HEX_API_KEY configured) - if: env.HEX_API_KEY == '' + - name: Skip Hex publish notice + if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY == '' run: | echo "::notice::HEX_API_KEY secret is not set on this repository." echo "::notice::Skipping Hex publish. Add the secret at" echo "::notice::https://github.com/${{ github.repository }}/settings/secrets/actions" - echo "::notice::and re-tag (or re-run this workflow) to publish." + echo "::notice::and re-run the workflow (Actions tab → Run workflow) to publish." From 3c89222e6d32b44b121dae845711c89a8f616d20 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 02:25:18 -0600 Subject: [PATCH 083/254] ci: per-step idempotency so re-runs back-fill missing pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the workflow had one all-or-nothing tag-exists check: if the tag was already there (e.g. pushed manually before this workflow existed), every later step was skipped — including the Hex publish. That stranded mob_dev 0.5.4 and mob_new 0.3.2: tagged + released on GitHub, never shipped to Hex. Now each step checks its own precondition: - Tag: git rev-parse refs/tags/ - GitHub Release: gh release view - Hex publish: mix hex.info | grep Config: So `workflow_dispatch` re-runs do exactly what's missing and no more. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 78 ++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 186d1348..2457397a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,12 @@ name: release # `version: "X.Y.Z"` line, commit, push — the workflow detects the change, # tags it, creates the GitHub Release, and publishes to Hex. # -# Re-running manually (e.g. to retry a failed Hex publish without bumping -# again) is via the Actions tab's "Run workflow" button (workflow_dispatch). +# Re-running manually (e.g. to back-fill a Hex publish that was skipped +# because the tag pre-existed) is via the Actions tab's "Run workflow" +# button (workflow_dispatch). Each step is independently idempotent: +# tag-already-exists, release-already-exists, and version-already-on-Hex +# are each detected per-step, so re-runs only do work that's actually +# missing. on: push: branches: [master] @@ -24,9 +28,9 @@ jobs: name: Release from mix.exs runs-on: ubuntu-latest env: - # Reads the repo secret. Below steps gate on `env.HEX_API_KEY != ''` - # so a missing secret skips the Hex publish cleanly instead of - # failing with an auth error. + # Reads the repo secret. Steps that need it gate on + # `env.HEX_API_KEY != ''` so a missing secret skips cleanly instead + # of failing with an auth error. HEX_API_KEY: ${{ secrets.HEX_API_KEY }} steps: - uses: actions/checkout@v4 @@ -42,9 +46,8 @@ jobs: id: version run: | # Pull the `version: "X.Y.Z"` from the project/0 keyword list. - # Tolerates leading whitespace, any quoting style, but expects - # a literal double-quoted string (which is the Elixir-mix - # convention). + # Tolerates leading whitespace, expects a literal double-quoted + # string (Elixir-mix convention). version=$(grep -E '^\s*version:\s*"' mix.exs | head -1 | sed 's/.*"\([^"]*\)".*/\1/') if [ -z "$version" ]; then echo "::error::Could not extract version from mix.exs" @@ -53,25 +56,34 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "Detected version: $version" - - name: Check if tag already exists - id: tag_check + # ── Tag (idempotent: skip if exists) ──────────────────────────── + - name: Create + push tag (if missing) run: | - if git rev-parse "refs/tags/${{ steps.version.outputs.version }}" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "::notice::Tag ${{ steps.version.outputs.version }} already exists — nothing to release. Bump mix.exs to create a new one." + tag="${{ steps.version.outputs.version }}" + if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then + echo "::notice::Tag $tag already exists — skipping tag creation" else - echo "exists=false" >> "$GITHUB_OUTPUT" + git tag "$tag" + git push origin "$tag" + echo "Created and pushed tag $tag" fi - - name: Create + push tag - if: steps.tag_check.outputs.exists == 'false' + # ── GitHub Release (idempotent: skip if exists) ───────────────── + - name: Check if GitHub Release exists + id: release_check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag="${{ steps.version.outputs.version }}" - git tag "$tag" - git push origin "$tag" + if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::GitHub Release $tag already exists — skipping release creation" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi - name: Extract CHANGELOG section - if: steps.tag_check.outputs.exists == 'false' + if: steps.release_check.outputs.exists == 'false' id: changelog run: | if [ -f CHANGELOG.md ]; then @@ -90,7 +102,7 @@ jobs: fi - name: Create GitHub Release - if: steps.tag_check.outputs.exists == 'false' + if: steps.release_check.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.version }} @@ -98,23 +110,41 @@ jobs: body_path: ${{ steps.changelog.outputs.has_body == 'true' && '/tmp/release-body.md' || '' }} generate_release_notes: ${{ steps.changelog.outputs.has_body != 'true' }} + # ── Hex publish (idempotent: skip if version already on Hex) ──── - name: Set up BEAM - if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY != '' + if: env.HEX_API_KEY != '' uses: erlef/setup-beam@v1 with: elixir-version: '1.19' otp-version: '28' + - name: Check if version is already on Hex + if: env.HEX_API_KEY != '' + id: hex_check + run: | + # `mix hex.info ` exits 0 if the version is published, + # non-zero otherwise. The package name is the project's `app:` value + # in mix.exs — same heuristic as for the version extract. + pkg=$(grep -E '^\s*app:\s*:' mix.exs | head -1 | sed 's/.*:\s*:\([a-z_][a-z0-9_]*\).*/\1/') + vsn="${{ steps.version.outputs.version }}" + if mix hex.info "$pkg" "$vsn" 2>/dev/null | grep -q "Config:"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::Hex package $pkg $vsn already published — skipping mix hex.publish" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + fi + - name: mix hex.publish - if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY != '' + if: env.HEX_API_KEY != '' && steps.hex_check.outputs.exists == 'false' # `--yes` skips the interactive confirm. `mix hex.publish` (no # subcommand) ships both the package archive AND docs in one call. run: | mix deps.get mix hex.publish --yes - - name: Skip Hex publish notice - if: steps.tag_check.outputs.exists == 'false' && env.HEX_API_KEY == '' + - name: Skip Hex publish notice (no API key) + if: env.HEX_API_KEY == '' run: | echo "::notice::HEX_API_KEY secret is not set on this repository." echo "::notice::Skipping Hex publish. Add the secret at" From bc0f24fea3ed77e9284601a013a6402dce50c20c Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sat, 16 May 2026 13:01:13 -0600 Subject: [PATCH 084/254] =?UTF-8?q?Mob.Camera:=20Android=20frame=20stream?= =?UTF-8?q?=20=E2=80=94=20CameraX=20ImageAnalysis=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the iOS-only restriction on start_frame_stream/stop_frame_stream. Previously the Android NIF returned :unsupported; now the Zig stubs call into Bridge.camera_start_frame_stream / _stop, which the host app implements as a CameraX ImageAnalysis use case bound to the shared lifecycle owner that the existing camera_preview already uses. * android/jni/mob_nif.zig: - nif_camera_start_frame_stream now takes the JSON opts binary + caller pid, forwards to Bridge.camera_start_frame_stream - nif_camera_stop_frame_stream calls Bridge.camera_stop_frame_stream - Bridge.camera_start_frame_stream / _stop method IDs cached at load time alongside camera_start_preview - New mob_deliver_camera_frame export: builds the {:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}} message and posts via enif_send. Mirrors the iOS AVCaptureVideoDataOutput delegate's message shape exactly. * android/jni/mob_beam.h: - Declares mob_deliver_camera_frame for the app-side JNI thunk * lib/mob/camera.ex: - @moduledoc updated: native side handles conversion on both iOS (vImage) and Android (CameraX + Bitmap) - :bgra_u8 doc mentions Android repacks ARGB → BGRA for parity App-side requirements (host MobBridge.kt + beam_jni.c) are out-of-band — see the nxeigen_probe sister commit for the reference shape. A future mob_dev follow-up should auto-generate the JNI thunk + MobBridge stubs so apps don't have to keep them in sync manually. Existing Mob.Camera Elixir tests still pass; the Zig + JNI integration is verified on the Moto G Power 5G (2024) PowerVR BXM-8-256 in nxeigen_probe (live YOLO at ~6s per frame including the conversion). --- android/jni/mob_beam.h | 3 ++ android/jni/mob_nif.zig | 76 +++++++++++++++++++++++++++++++++++++---- lib/mob/camera.ex | 17 ++++----- mix.exs | 2 +- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 0343f86f..1df407c0 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -102,6 +102,9 @@ void mob_deliver_location(jlong pid, double lat, double lon, double acc, double void mob_deliver_motion(jlong pid, double ax, double ay, double az, double gx, double gy, double gz, long long ts); void mob_deliver_file_result(jlong pid, const char *event, const char *sub, const char *json_items); +void mob_deliver_camera_frame(jlong pid, const unsigned char *bytes, size_t nbytes, + int width, int height, const char *format, + jlong timestamp_ms, jlong dropped); void mob_deliver_push_token(jlong pid, const char *token); void mob_deliver_notification(jlong pid, const char *json); void mob_set_launch_notification(const char *json); diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index e65549fd..69465d90 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -160,6 +160,8 @@ pub const BridgeMethods = extern struct { camera_capture_video: jni.JMethodID = null, camera_start_preview: jni.JMethodID = null, camera_stop_preview: jni.JMethodID = null, + camera_start_frame_stream: jni.JMethodID = null, + camera_stop_frame_stream: jni.JMethodID = null, alert_show: jni.JMethodID = null, action_sheet_show: jni.JMethodID = null, toast_show: jni.JMethodID = null, @@ -1993,6 +1995,56 @@ pub export fn mob_deliver_file_result( _ = erts.enif_send(null, &pid, env, msg); } +/// `mob_deliver_camera_frame` — called from beam_jni.c after a +/// CameraX ImageAnalysis frame has been converted to the requested +/// pixel format. Posts the iOS-equivalent +/// `{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}` +/// message to the BEAM caller pid. The `bytes` payload is copied into +/// a fresh BEAM binary so the caller can release the underlying Kotlin +/// ByteArray as soon as this function returns. +pub export fn mob_deliver_camera_frame( + jpid: jni.JLong, + bytes: [*]const u8, + nbytes: usize, + width: c_int, + height: c_int, + format: [*:0]const u8, + timestamp_ms: jni.JLong, + dropped: jni.JLong, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + var pix: erts.ErlNifBinary = undefined; + if (erts.enif_alloc_binary(nbytes, &pix) == 0) return; + @memcpy(pix.data[0..nbytes], bytes[0..nbytes]); + + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "bytes"), + erts.enif_make_atom(env, "width"), + erts.enif_make_atom(env, "height"), + erts.enif_make_atom(env, "format"), + erts.enif_make_atom(env, "timestamp_ms"), + erts.enif_make_atom(env, "dropped"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_binary(env, &pix), + erts.enif_make_int(env, width), + erts.enif_make_int(env, height), + erts.enif_make_atom(env, format), + erts.enif_make_int64(env, timestamp_ms), + erts.enif_make_int64(env, dropped), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.atom(env, "camera"), + erts.atom(env, "frame"), + payload, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + pub export fn mob_deliver_push_token(jpid: jni.JLong, token: [*:0]const u8) callconv(.c) void { var pid = pidFromLong(jpid); const env = erts.enif_alloc_env() orelse return; @@ -2326,18 +2378,22 @@ export fn nif_camera_stop_preview( return erts.ok(env); } -// Live camera frame stream — Android implementation pending (needs -// Camera2 + ImageAnalysis wiring on the Kotlin side). Returns -// :unsupported for now so the iOS demo unblocks without breaking the -// Android build. Track in https://github.com/GenericJam/mob/issues +// Live camera frame stream. CameraX ImageAnalysis on the Kotlin side +// converts YUV → RGB f32, then calls back via +// `nativeDeliverCameraFrame` → `mob_deliver_camera_frame` to post a +// `{:camera, :frame, %{...}}` message to the caller pid. export fn nif_camera_start_frame_stream( env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const erts.ERL_NIF_TERM, ) callconv(.c) erts.ERL_NIF_TERM { _ = argc; - _ = argv; - return erts.atom(env, "unsupported"); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.camera_start_frame_stream, pid, json); } export fn nif_camera_stop_frame_stream( @@ -2347,7 +2403,11 @@ export fn nif_camera_stop_frame_stream( ) callconv(.c) erts.ERL_NIF_TERM { _ = argc; _ = argv; - return erts.atom(env, "unsupported"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.camera_stop_frame_stream); + detachIfAttached(attached); + return erts.ok(env); } export fn nif_photos_pick( @@ -3177,6 +3237,8 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c if (!cacheRequired(jenv, "camera_capture_video", "(JLjava/lang/String;)V", &Bridge.camera_capture_video)) return -1; if (!cacheRequired(jenv, "camera_start_preview", "(JLjava/lang/String;)V", &Bridge.camera_start_preview)) return -1; if (!cacheRequired(jenv, "camera_stop_preview", "()V", &Bridge.camera_stop_preview)) return -1; + if (!cacheRequired(jenv, "camera_start_frame_stream", "(JLjava/lang/String;)V", &Bridge.camera_start_frame_stream)) return -1; + if (!cacheRequired(jenv, "camera_stop_frame_stream", "()V", &Bridge.camera_stop_frame_stream)) return -1; if (!cacheRequired(jenv, "photos_pick", "(JLjava/lang/String;)V", &Bridge.photos_pick)) return -1; if (!cacheRequired(jenv, "files_pick", "(JLjava/lang/String;)V", &Bridge.files_pick)) return -1; if (!cacheRequired(jenv, "audio_start_recording", "(JLjava/lang/String;)V", &Bridge.audio_start_recording)) return -1; diff --git a/lib/mob/camera.ex b/lib/mob/camera.ex index 96b9dddb..06f534b9 100644 --- a/lib/mob/camera.ex +++ b/lib/mob/camera.ex @@ -29,10 +29,11 @@ defmodule Mob.Camera do format: :rgb_f32, timestamp_ms: t, dropped: n}}, socket) - The native side handles resize + format conversion (vImage on iOS) so - the BEAM never sees raw camera buffers. Late frames are dropped on - the native side — the BEAM mailbox can't unbounded-grow if your - receiver lags behind the camera's 30 fps cadence. + The native side handles resize + format conversion (vImage on iOS, + CameraX ImageAnalysis + Bitmap on Android) so the BEAM never sees + raw camera buffers. Late frames are dropped on the native side — + the BEAM mailbox can't unbounded-grow if your receiver lags behind + the camera's 30 fps cadence. """ @doc """ @@ -107,10 +108,10 @@ defmodule Mob.Camera do - `:rgb_f32` (default) — interleaved RGB floats normalised to `[0.0, 1.0]`. Byte size: `width * height * 3 * 4`. Ready for `Nx.from_binary(bin, :f32, ...) |> Nx.reshape({1, h, w, 3})`. - - `:bgra_u8` — raw 32-bit BGRA bytes, native iOS pixel layout. - Byte size: `width * height * 4`. 4× smaller than `:rgb_f32`; - useful for forwarding to another NIF or doing custom - preprocessing. + - `:bgra_u8` — raw 32-bit BGRA bytes (native iOS pixel layout; + Android repacks ARGB → BGRA for parity). Byte size: + `width * height * 4`. 4× smaller than `:rgb_f32`; useful for + forwarding to another NIF or doing custom preprocessing. * `:facing` — `:back` (default) or `:front`. Same camera the preview uses; calling `start_frame_stream/2` alone will activate diff --git a/mix.exs b/mix.exs index 862c6a84..7431defc 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.1", + version: "0.6.2", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From d297fe011a6688e5ae5a066b1cab9ca1189eae47 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 16:19:19 -0600 Subject: [PATCH 085/254] Release flow standardisation: RELEASE.md + pre-push hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a canonical release-process doc (RELEASE.md) covering the mix.exs-driven trigger model, the patch-bump-default-and-always-ask rule, CHANGELOG conventions, and the per-step idempotency of release.yml. mob_dev and mob_new link here rather than duplicating it. .githooks/pre-push enforces the cheap preflight (format + credo + warnings-as-errors) on every push and the full release preflight (test suite + mob.security_scan when present) only when mix.exs changed. The test suite stays out of the always-tier so people don't reach for --no-verify — CI is still the authoritative gate. Activate per clone/worktree with `git config core.hooksPath .githooks` (documented in CLAUDE.md). Co-Authored-By: Claude Opus 4.7 --- .githooks/pre-push | 79 ++++++++++++++++++++++++ CLAUDE.md | 23 +++++++ RELEASE.md | 146 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100755 .githooks/pre-push create mode 100644 RELEASE.md diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..4364368b --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# .githooks/pre-push — Mob repo pre-push gate. +# +# Activated per-clone with: git config core.hooksPath .githooks +# See RELEASE.md for the full release flow + when this hook runs. +# +# Two tiers of check: +# +# ALWAYS (cheap, sub-10s): +# mix format --check-formatted +# mix credo --strict +# mix compile --warnings-as-errors +# +# ONLY WHEN mix.exs CHANGED IN THIS PUSH (release preflight): +# mix test --exclude macos_only --exclude requires_zig +# +# Intentionally NOT in the always-tier: the full test suite. It's a +# 30-60s wait per push and that's exactly what teaches people to reach +# for --no-verify. CI runs the suite on every push regardless; this +# hook is here to catch the 80% of breaks that the fast checks find. +set -euo pipefail + +# Suppress noisy OTP-28 regex warning so the hook output is clean. +export ELIXIR_ERL_OPTIONS="-elixir ansi_enabled true" + +cheap_checks() { + echo "[pre-push] format..." + mix format --check-formatted + + echo "[pre-push] credo..." + mix credo --strict + + echo "[pre-push] compile (--warnings-as-errors)..." + mix compile --warnings-as-errors +} + +release_preflight() { + echo "[pre-push] mix.exs changed → running release preflight..." + mix test --exclude macos_only --exclude requires_zig + + # mix mob.security_scan only exists in mob_dev. Gate on availability + # so the same hook script can live unchanged in mob / mob_dev / mob_new. + if mix help mob.security_scan >/dev/null 2>&1; then + echo "[pre-push] mob.security_scan..." + mix mob.security_scan + fi +} + +# git invokes pre-push with no args but pipes +# +# on stdin, one line per ref being pushed. We diff the local sha +# against the remote sha to see what mix.exs looks like in this push. +zero=0000000000000000000000000000000000000000 +mix_exs_changed=false + +while read -r local_ref local_sha remote_ref remote_sha; do + # Branch deletion (local_sha all zeros) → nothing to diff. + [ "$local_sha" = "$zero" ] && continue + + if [ "$remote_sha" = "$zero" ]; then + # New branch on the remote — diff against origin/master so we + # don't try to scan an open-ended commit range. + range="origin/master..$local_sha" + else + range="$remote_sha..$local_sha" + fi + + if git diff --name-only "$range" 2>/dev/null | grep -qx 'mix.exs'; then + mix_exs_changed=true + fi +done + +cheap_checks + +if [ "$mix_exs_changed" = "true" ]; then + release_preflight +fi + +echo "[pre-push] ✓ all checks passed" diff --git a/CLAUDE.md b/CLAUDE.md index 636713dd..b2a1fe4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,29 @@ manually with a screenshot or `Mob.Test` interaction before committing. --- +## Release flow + +See [`RELEASE.md`](RELEASE.md) for the canonical release process — +trigger model (mix.exs is the source of truth), version-bump rules +(patch default, always ask, never auto-bump), CHANGELOG conventions, +local preflight, and the per-step idempotency of `release.yml`. + +**Pre-push hook**: `.githooks/pre-push` runs `mix format +--check-formatted`, `mix credo --strict`, and `mix compile +--warnings-as-errors` on every push (~5-10 s). When the push touches +`mix.exs` it additionally runs the full test suite as the release +preflight. The hook is committed in the repo; activate it once per +clone or worktree with: + +```bash +git config core.hooksPath .githooks +``` + +git stores `core.hooksPath` locally per-clone, so every worktree +needs the same one-liner. + +--- + ## Native App Test Harness — Vision ### What mob is (beyond the UI framework) diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..ed7aab57 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,146 @@ +# Release flow + +Canonical release process for the Mob repos (`mob`, `mob_dev`, +`mob_new`). `mob_dev` and `mob_new` reference this file rather than +duplicating it; each adds a short per-repo notes section in its own +CLAUDE.md. + +## Trigger model + +`mix.exs` is the single source of truth for the version. The `release` +GitHub Actions workflow fires when: + +- A push to `master` modifies `mix.exs` +- `workflow_dispatch` is invoked manually (Actions tab → "Run + workflow") + +Any other push to `master` is ignored by the release workflow. Each +step in the workflow (tag, GitHub Release, Hex publish) is +independently idempotent — re-running back-fills only what's missing. + +## Version bump rule + +**Default: patch (`0.x.y → 0.x.(y+1)`).** Always ask before bumping +any version — never auto-bump as part of a feature commit. Reach for +minor only when: + +- A public API broke or was removed +- Several substantive features land in one cut +- A build-system migration or framework architectural shift is + complete + +When unsure, propose patch and confirm with the user. Cheaper to +upgrade after agreement than to downgrade after a commit lands. + +## Step-by-step + +### 1. Update `CHANGELOG.md` + +Add a new `## [X.Y.Z]` section at the top (below the `---` +separator), with `### Added` / `### Changed` / `### Fixed` / +`### Removed` subsections as needed. The release workflow extracts +this section verbatim into the GitHub Release body, so write it for a +reader who hasn't been in the room. + +### 2. Bump `mix.exs` + +Edit the `version: "X.Y.Z"` line in the `project/0` keyword list. +Nothing else moves the workflow trigger. + +### 3. Run the local preflight + +```bash +mix format --check-formatted +mix credo --strict +mix compile --warnings-as-errors +mix test --exclude macos_only --exclude requires_zig +``` + +These are the same checks `test.yml` runs in CI. Catching them +locally saves a 3-5 min CI round-trip per fix iteration. The +pre-push hook (`.githooks/pre-push`) runs the cheap checks +automatically; the `mix test` step is only required when `mix.exs` +changed in the push (i.e., you're actually cutting a release). + +Per-repo extras: + +- **`mob_dev`**: also run `mix mob.security_scan` — it's the only + repo that ships the scanner. The `hex_deps` layer applies to + mob_dev itself; the gradle / swift / bundled_runtime layers no-op + (mob_dev has no native surface). +- **`mob_new`**: generator tests need `MOB_DIR=/Users/kevin/code/mob` + when running from a worktree; the path resolver looks for `mob` + alongside the project. + +### 4. Commit + push + +One commit per release. The commit message convention: + +``` +Bump to X.Y.Z — + + +``` + +Push to `master`. The release workflow fires automatically because +`mix.exs` changed. + +### 5. Watch the workflow + +```bash +gh run watch -R GenericJam/ +``` + +A successful run does three things in order, each independently +idempotent: + +1. Creates and pushes tag `X.Y.Z` (skipped if it already exists) +2. Creates the GitHub Release `X.Y.Z` with the CHANGELOG section as + body (skipped if it already exists) +3. Publishes to Hex via `mix hex.publish --yes` (skipped if `mix + hex.info ` already finds the version) + +If a step fails partway through (network, transient Hex 503, etc.) +re-run the workflow via `workflow_dispatch` — only the missing steps +will execute. + +## Pre-push hook + +`.githooks/pre-push` runs the **cheap** preflight on every push: + +``` +mix format --check-formatted +mix credo --strict +mix compile --warnings-as-errors +``` + +Sub-10-second total. If `mix.exs` changed in the push, it additionally +runs the full test suite (the "release preflight"). Tests are NOT run +on every push — that's a CI responsibility, and forcing local 30-60s +test runs is what drives people to `--no-verify` (anti-pattern). + +**One-time setup** after cloning the repo (or creating a new worktree): + +```bash +git config core.hooksPath .githooks +``` + +git stores this locally per-clone, so each worktree needs it too. To +intentionally bypass on a specific push (rare — be honest about why): + +```bash +git push --no-verify +``` + +## OTP tarball releases (mob_dev only) + +The OTP runtime tarballs at `github.com/GenericJam/mob/releases/tag/otp-` +are a **separate, manual** release flow — not driven by `mix.exs` +version bumps. See `scripts/release/` in `mob_dev` for the build + +publish scripts. The version-bump flow above only ships the Elixir +package; OTP tarball rebuilds are operator steps run when the OTP +source revision or cross-compile flags change. + +When you bump `@otp_hash` in `mob_dev/lib/mob_dev/otp_downloader.ex` +to point at a new tarball release, the version bump that ships that +change to Hex still follows the standard flow above. From 6129f29d6ac93775437192e1a5d6cf01ff39bb93 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 16:27:53 -0600 Subject: [PATCH 086/254] =?UTF-8?q?Bump=20to=200.6.6=20=E2=80=94=20release?= =?UTF-8?q?-flow=20docs=20+=20when-to-bump=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the new RELEASE.md to Hex (and therefore hexdocs.pm) so the release process — including the bump rules added in this release (doc-only improvements warrant a bump because hexdocs is built from the published version; tests-and-docs are non-negotiable for new functionality) — is visible to anyone reading from outside the repo, not just contributors with a local clone. Also adds the .githooks/ scaffolding and the CLAUDE.md "Release flow" section that points to RELEASE.md. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 18 ++++++++++++++++++ RELEASE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ mix.exs | 2 +- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7cb28f..056e826a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [0.6.6] + +### Added +- `RELEASE.md` — canonical release-process documentation covering the + mix.exs-driven trigger model, the patch-bump-default-with-mandatory- + permission rule, CHANGELOG conventions, when a bump is warranted (new + functionality, bug fixes, doc improvements, dep bumps) vs. when it + isn't (CI tweaks, hook changes, internal refactors), the + tests-and-docs-with-new-functionality non-negotiables, and the + per-step idempotency of `release.yml`. Linked from `mob_dev` and + `mob_new` CLAUDE.md by URL so the canonical process is one file. +- `.githooks/pre-push` — committed pre-push hook that runs the cheap + preflight (format + credo + warnings-as-errors) on every push and + the full release preflight (test suite + `mob.security_scan` where + present) only when `mix.exs` changed. Activate per-clone with + `git config core.hooksPath .githooks`. +- `CLAUDE.md` "Release flow" section linking to the new docs. + ## [0.6.5] ### Fixed diff --git a/RELEASE.md b/RELEASE.md index ed7aab57..83951573 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -32,6 +32,57 @@ minor only when: When unsure, propose patch and confirm with the user. Cheaper to upgrade after agreement than to downgrade after a commit lands. +## When a bump is warranted + +A version bump isn't only for code changes. Cut a patch release any +time the published artifact would meaningfully differ: + +- **New functionality** — any added public function, new component + attribute, new template, new Mix task. Must ship with **tests** that + exercise the new behaviour and **docs** in the right place + (module/function `@doc`, guides under `guides/`, or template + comments for generator changes). +- **Bug fix** affecting behaviour visible to downstream apps. +- **Doc improvements** — module docstring rewrites, guide additions, + README clarifications. HexDocs is built from the published Hex + release, so doc-only changes without a bump never reach + hexdocs.pm. If a contributor improved how the library is documented, + the bump is what makes that improvement visible. +- **Dependency bump** that downstream consumers should pick up + (security advisory, transitive runtime fix). + +NOT warranted on their own: + +- CI workflow tweaks (`.github/workflows/*`) +- Pre-push hook changes (`.githooks/*`) +- Internal test refactors that don't change behaviour +- Worktree cleanup, gitignore edits + +When in doubt: if the next person to pull from Hex would benefit from +having this change, bump. If it only affects contributors working in +the repo directly, don't. + +## Tests + docs for new functionality + +Two non-negotiables for anything that ships: + +1. **Tests cover the new behaviour.** A unit test asserting the new + public API works as advertised. For renderer changes, + `test/mob/renderer_test.exs` is the canonical pattern; for + generator-template additions, assert on the rendered output via + `MobNew.ProjectGeneratorTest`. Tests that exist but don't fail + when the feature is broken don't count. +2. **Docs land in the right place.** Module-level `@moduledoc` for + the WHY a module exists, function-level `@doc` for any non-obvious + public function. Cross-cutting topics belong in `guides/` (e.g. + `guides/styling.md`, `guides/security.md`). Generator templates + document inline via comments since they ship verbatim into user + apps. + +The pre-push hook does NOT enforce these — they require human +judgement (a test asserting `1 + 1 == 2` technically exists). But +they're table stakes for any commit that warrants a version bump. + ## Step-by-step ### 1. Update `CHANGELOG.md` diff --git a/mix.exs b/mix.exs index 918247eb..20c1d492 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.5", + version: "0.6.6", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), From 848014e1828c9329e06a50eba8d4373a0047676c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 16 May 2026 16:33:29 -0600 Subject: [PATCH 087/254] RELEASE.md: document mix docs preview + automatic hexdocs publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two paragraphs to the "Tests + docs for new functionality" section: 1. After editing @moduledoc / @doc, run `mix docs` locally and open doc/index.html. Catches common ExDoc gotchas (heredoc code fence spacing, unresolved module refs rendering as `nil`, missing table-blank-line) before they reach hexdocs.pm. 2. hexdocs publish is automatic — `mix hex.publish --yes` (called by release.yml) ships package + docs in one step. A correct version bump is what makes new docs visible; no separate "publish docs" action exists. Not bumping for this change — will ride along on the next release of mob/mob_dev/mob_new. Co-Authored-By: Claude Opus 4.7 --- RELEASE.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 83951573..f316b941 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -79,9 +79,23 @@ Two non-negotiables for anything that ships: document inline via comments since they ship verbatim into user apps. +After writing or substantially editing `@moduledoc` / `@doc` strings, +run `mix docs` locally and open `doc/index.html` to confirm +rendering. Common gotchas: heredoc strings need a blank line before +code fences; ExDoc resolves `Mob.Foo` references but not +`Mob.Foo.bar` without backticks; broken module refs render as `nil` +instead of a link; tables need an empty line above to render. +Local preview catches these before they reach hexdocs.pm. + +Publishing to hexdocs.pm is **automatic** — `release.yml`'s +`mix hex.publish --yes` step ships package + docs in one call. There +is no separate "publish docs" step. A correct version bump (and only +that) is what makes new docs visible at hexdocs.pm//. + The pre-push hook does NOT enforce these — they require human -judgement (a test asserting `1 + 1 == 2` technically exists). But -they're table stakes for any commit that warrants a version bump. +judgement (a test asserting `1 + 1 == 2` technically exists, an +empty `@doc ""` technically has docs). But they're table stakes for +any commit that warrants a version bump. ## Step-by-step From a88135e0d963a71f6775676f50cd1fad55fe0e34 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 17 May 2026 00:31:27 -0600 Subject: [PATCH 088/254] Mob.Canvas: document the viewport-scaling contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob ships no host-app Kotlin / Swift code, so each app's MobBridge.kt contains its own Canvas renderer. The original per-app implementations treated coordinates as raw pixels (or as dp with no viewport scaling), which made every draw op land in the wrong place on devices where 1 dp != 1 px — i.e., every modern Android device. Bounding-box overlays over were the most visible victim; the bboxes appeared shifted down-and-right of the actual subject. The fix that landed in nxeigen_probe's MobBridge.kt is to treat the Canvas component's `width` and `height` props as a **logical viewport**: inside DrawScope, compute `sx = size.width / width` and `sy = size.height / height`, and multiply every coord by them. Scalar sizes (stroke widths, radii, text sizes) use the average so they don't squash when the viewport is non-square. This commit pins that as the documented contract: * Mob.Canvas @moduledoc — new "Coordinate system" and "Implementing the renderer" sections. The first sentence ("All coordinates are canvas-local in points/dp") was already aspirationally accurate; the new text makes it operationally precise and provides the reference Compose recipe. * test/mob/canvas_test.exs — two new tests that pin the contract at the wire boundary (the wire carries logical numbers; no density / scale info is encoded; the renderer is responsible for translating). * guides/troubleshooting.md — new entry "Mob.Canvas draw ops appear shifted, cropped, or in the wrong place" with the symptom, cause, and the per-app fix recipe. * PLAN.md — flag the bigger MobBridge.kt-duplication smell as a follow-up. The canvas bug is the first concrete cost of that duplication; it won't be the last. Tests: 743 (was 741). Credo clean. mix format applied. v0.6.2 → v0.6.3. --- PLAN.md | 41 ++++++++++++++++++++++++++ guides/troubleshooting.md | 43 ++++++++++++++++++++++++++++ lib/mob/canvas.ex | 60 +++++++++++++++++++++++++++++++++++++-- mix.exs | 2 +- test/mob/canvas_test.exs | 36 +++++++++++++++++++++++ 5 files changed, 178 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 39bdcb42..6796cfbc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2252,3 +2252,44 @@ If batch 5+ benchmarks show meaningful overhead, add per-category enable so subscribers only register OS observers they actually use. For batches 1–4 this isn't worth the API surface — the cost is dominated by the OS firing the notification, which happens regardless of whether we observe. + +--- + +## MobBridge.kt / MobBridge.swift duplication (drift hazard) + +Today each Mob app carries its own copy of `MobBridge.kt` (and the iOS +equivalent). They're scaffolded once and then diverge — `nxeigen_probe`'s +is 3068 lines; `mob_lv_test`'s is 1657. When Mob adds a feature that +needs Kotlin support (e.g., the camera frame stream wiring earlier this +month) every app has to be patched independently. When a Kotlin-side bug +is fixed in one app (e.g., the canvas viewport-scaling fix that landed +in nxeigen_probe — see `Mob.Canvas` `@moduledoc` and +`guides/troubleshooting.md`) the fix doesn't propagate. + +This is sustainable while there are ~2 Mob apps. It will become a real +problem at ~10. + +**Options:** + +* **Ship MobBridge as an AAR / Swift package** the apps depend on. + Per-app `MobBridge.kt` becomes a thin shim that just registers + app-specific things (the app's package name for JNI, app-specific + intent filters, etc.). The bulk of the renderer / UI / Compose code + is library-managed. +* **Generate MobBridge from a single Elixir source** during + `mix mob.deploy --native`. Like Phoenix's `mix phx.gen.*`, but as + a regenerate-on-every-build step rather than a one-shot scaffold. + Apps wouldn't edit MobBridge by hand at all. +* **Status quo with strong cross-app diff tooling** — a + `mix mob.audit_bridge` task that diffs all known MobBridge.kt's and + flags drift. Cheap to implement but doesn't fix the root cause. + +The AAR / Swift-package path is cleanest but has a real engineering +cost (Compose-in-library packaging on Android is finicky; SwiftPM +target setup is finicky). The generator path is the smallest +incremental change from today's scaffold. + +**First known bug caused by this duplication:** Canvas viewport +scaling (pixel-vs-logical-units). See `Mob.Canvas` `@moduledoc` +section "Implementing the renderer" for the per-app fix recipe; the +duplication issue is the meta-problem. diff --git a/guides/troubleshooting.md b/guides/troubleshooting.md index ba5c6551..0236eca5 100644 --- a/guides/troubleshooting.md +++ b/guides/troubleshooting.md @@ -358,3 +358,46 @@ See the [DNS on iOS guide](dns_on_ios.md) for the full story, including why manual resolution rather than automatic interception, what to do if the IP changes mid-session, and which libraries (NIFs that do their own `getaddrinfo`) don't need this fix. + +--- + +## `Mob.Canvas` draw ops appear shifted, cropped, or in the wrong place + +**Symptom:** Lines, rectangles, or other Canvas draw operations land at +the wrong screen coordinates. Bounding boxes drawn over a +`` are noticeably offset (typically down-and-right on +high-density Android devices, or off by some scale factor) and may +extend outside the visible canvas area. + +**Cause:** The host app's `MobBridge` Canvas renderer is interpreting +coordinates as raw pixels (or as dp with no viewport scaling) instead +of treating the Canvas's declared `width` / `height` props as a +logical viewport. The intended contract is documented in +`Mob.Canvas`'s `@moduledoc`: a draw op at `(width / 2, height / 2)` +lands in the dead centre of the rendered canvas regardless of actual +pixel size or device density. Older / scaffolded `MobBridge.kt`s +predate this contract and shipped a 1 coord = 1 pixel renderer. + +**Fix:** Apply the viewport-scaling recipe documented in +`Mob.Canvas`'s `@moduledoc` ("Implementing the renderer" section) to +your app's `MobBridge.kt` `MobCanvas` composable. Short version: +inside `Canvas { ... }`, compute + +```kotlin +val sx = if (width > 0f) size.width / width else 1f +val sy = if (height > 0f) size.height / height else 1f +``` + +and multiply every x-coord / width by `sx` and every y-coord / height +by `sy` inside `drawCanvasOp`. Scalar sizes (stroke widths, circle +radii, text sizes) use the average `(sx + sy) / 2` so they don't +squash when the viewport is non-square. + +The same fix applies to `MobBridge.swift` on iOS — Compose and SwiftUI +both deliver pixel-space draw scopes that need translating. + +**Why this isn't fixed once-and-for-all in Mob itself:** Mob ships +zero host-app Kotlin / Swift today; every app's `MobBridge` is its +own diverged copy. A future Mob improvement is to ship the renderer +as a generated module or an AAR / Swift package so this kind of +contract drift can't happen. Tracked in PLAN.md. diff --git a/lib/mob/canvas.ex b/lib/mob/canvas.ex index 7dcb38f8..efeec0fc 100644 --- a/lib/mob/canvas.ex +++ b/lib/mob/canvas.ex @@ -8,9 +8,63 @@ defmodule Mob.Canvas do raw strings ("#ff0000") — they are resolved by `Mob.Renderer` against the active theme before serialisation to the native side. - All coordinates are canvas-local in points/dp, top-left origin - (matches SwiftUI `Canvas` and Jetpack Compose `Canvas` natively, no - translation cost). + ## Coordinate system (important — read this once) + + All coordinates are **canvas-local logical units**, top-left origin. + The unit is whatever the host app's `` component declared + via the `width` and `height` props on the canvas — a draw op at + `(width / 2, height / 2)` lands in the dead centre of the rendered + canvas regardless of the canvas's actual on-screen pixel size. + + This deliberately differs from raw Compose `DrawScope.size` (which + is in pixels) and from raw SwiftUI `Canvas` (which is in points). + The renderer multiplies every coordinate by + `(actual_pixels / declared_logical_units)` per axis so callers + don't have to thread density and parent-constraint information + through every draw call. + + Practical consequence: a YOLO model that outputs bbox coords in + `0..640` can be drawn directly on a `` + and the boxes will line up with the underlying preview image + regardless of the actual on-screen size or device density. + + See "Implementing the renderer" below for the contract the host + app's Kotlin / Swift `MobBridge` must honor. + + ## Implementing the renderer (host app's `MobBridge`) + + Mob ships no host-app code; each app's `MobBridge.kt` / + `MobBridge.swift` contains the Canvas renderer. The viewport-scaling + contract above is non-obvious and easy to get wrong — the original + per-app implementations interpreted coordinates as raw pixels, which + made bounding-box overlays drift on every device where 1 dp ≠ 1 px + (i.e., every modern Android device). Reference recipe for Compose: + + @Composable + private fun MobCanvas(node: MobNode, modifier: Modifier) { + val width = floatProp(node.props, "width") ?: 0f + val height = floatProp(node.props, "height") ?: 0f + val ops = ... // List> + + val sized = if (width > 0f && height > 0f) + modifier.size(width.dp, height.dp) else modifier + + Canvas(modifier = sized) { + // size.width / size.height are in PIXELS. + val sx = if (width > 0f) size.width / width else 1f + val sy = if (height > 0f) size.height / height else 1f + ops.forEach { op -> drawCanvasOp(op, sx, sy) } + } + } + + Every coord then passes through `coord * sx` / `coord * sy` in the + draw step. Scalar sizes (stroke widths, circle radii, text sizes) + use the average `(sx + sy) / 2` so they don't squash when the + declared viewport is non-square. + + See `nxeigen_probe`'s + `android/app/src/main/java/com/example/nxeigen_probe/MobBridge.kt` + for the full working implementation. ## Op map equivalence diff --git a/mix.exs b/mix.exs index 7431defc..a7ffbe5d 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.6.2", + version: "0.6.3", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), diff --git a/test/mob/canvas_test.exs b/test/mob/canvas_test.exs index e2c539b4..0530ccfc 100644 --- a/test/mob/canvas_test.exs +++ b/test/mob/canvas_test.exs @@ -245,4 +245,40 @@ defmodule Mob.CanvasTest do assert via_helper == via_literal end end + + describe "coordinate system contract (pinned, for the renderer)" do + # These tests don't render anything — the actual viewport scaling + # happens in the host app's MobBridge.kt / MobBridge.swift. The + # tests pin the *contract* the renderer must honor, so that + # contract is captured in code and a future renderer rewrite has + # something concrete to test against. + + test "coordinates are canvas-local logical units, not pixels" do + # A draw op at (width/2, height/2) must land in the center of + # the canvas regardless of the canvas's actual rendered pixel + # size. The wire format carries the logical numbers; the host + # renderer applies (size.pixels / declared_logical_units) per + # axis. See Mob.Canvas @moduledoc "Coordinate system" section. + op = Canvas.circle(320, 240, 10, color: :primary, fill: true) + assert op.x == 320 + assert op.y == 240 + # No density / scale information is encoded into the wire — the + # renderer derives it from the actual composable size at draw + # time. + refute Map.has_key?(op, :density) + refute Map.has_key?(op, :scale) + end + + test "scalar sizes (stroke width, radius, text size) are logical units too" do + # The renderer must scale these by (sx + sy) / 2 so they don't + # squash when the viewport is non-square. The wire carries the + # raw numbers; no per-axis hint. + stroke = Canvas.line(0, 0, 100, 100, color: :primary, width: 4) + assert stroke.width == 4 + refute Map.has_key?(stroke, :width_px) + + circ = Canvas.circle(50, 50, 12, color: :primary) + assert circ.r == 12 + end + end end From e7b6402cc8ac7447e888c7a603e6b8745e94cd8f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 17 May 2026 03:13:23 -0600 Subject: [PATCH 089/254] MOB_PLUGINS.md: plugin manifest schema spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-pass spec for the third-party plugin system. Covers: - Five tiers from "pure Elixir helper" (no manifest needed) up to "embedded sub-app with lifecycle / settings / notifications" - Concrete-syntax manifest examples per tier, each annotated - Two-step install: `deps + mix deps.get` makes the plugin available; explicit `config :mob, :plugins, [...]` activates its contributions (permissions, native code, etc.). Prevents silent supply-chain footguns. - `mix mob.add_plugin` as convenience wrapper that does both steps plus walks the plugin's interactive setup - Full schema reference + validation rules (run by `mix mob.validate_plugin` for authors, by mob_dev for consumers) - Forward-compat via plugin_spec_version integer - Hot-push compatibility table (tier 0 yes; tiers 1+ require rebuild for native code, partial for Elixir-side changes) Spec only — no implementation. Sits alongside RELEASE.md as the other canonical operating doc. Not bumping for this; rides along with the next release. Co-Authored-By: Claude Opus 4.7 --- MOB_PLUGINS.md | 517 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 MOB_PLUGINS.md diff --git a/MOB_PLUGINS.md b/MOB_PLUGINS.md new file mode 100644 index 00000000..c6d209fa --- /dev/null +++ b/MOB_PLUGINS.md @@ -0,0 +1,517 @@ +# Mob plugins — manifest schema + +Mob plugins are regular Hex packages with a `priv/mob_plugin.exs` data +file. The data file declares what the plugin contributes (NIFs, UI +components, screens, permissions, etc.) and mob_dev's compile step +autolinks those contributions into the host app's build. + +This doc covers: + +- The five plugin tiers and what each one ships +- The manifest schema, annotated with concrete examples +- Install + activation flow +- Validation + compatibility rules + +For the surrounding ecosystem questions (why Hex, why a manifest, +plugin authoring via `mix mob.new_plugin`), see `RELEASE.md` and the +relevant guides. + +## Plugin tiers + +Plugins range from "10 lines of helper code" to "embedded chat app." +The manifest scales — small plugins use 3 fields, big plugins use a +dozen. Every section below the required header is optional; you only +write what you need. + +| Tier | Example | What it ships | Hot-pushable? | +|--|--|--|--| +| 0 | `mob_color_palette` | Pure Elixir module, no native, no manifest | Yes (regular Hex pkg) | +| 1 | `mob_haptic_extras` | NIF + Elixir wrapper | No (native rebuild) | +| 2 | `mob_signature_pad` | + new `` component | No | +| 3 | `mob_in_app_purchase` | + `Mob.Screen` modules, migrations, assets | No | +| 4 | `mob_chat_kit` | + lifecycle hooks, settings, notification handlers | No | + +A tier-0 plugin doesn't need this spec at all — it's just a Hex +package depending on `:mob`. The manifest matters from tier 1 +upward. + +## Minimum viable manifest (tier 1) + +```elixir +# priv/mob_plugin.exs +%{ + name: :mob_haptic_extras, + mob_version: "~> 0.6", + plugin_spec_version: 1 +} +``` + +Three required fields, that's it. A manifest this small means "this +plugin's contributions are entirely in the lib/ folder, no native +code, no permissions." Functionally equivalent to a tier-0 plugin +but allows mob_dev to print it in `mix mob.plugins` output and +enforce the `mob_version` constraint at compile time. + +Add fields below as you need them. Every section is independently +optional. + +## Tier 1 — functional plugin + +A NIF + Elixir wrapper + per-platform helper code that doesn't touch +the render tree. The canonical example is what `Mob.Bt` would look +like if it lived outside core: + +```elixir +%{ + name: :mob_bluetooth, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Bluetooth Classic peripheral (HFP / SPP / HID)", + + # Static-linked NIFs. Each entry is the Elixir module that calls + # `Mob.StaticNif.load/1` plus the directory of native sources. + # mob_dev's build appends these to the existing :static_nifs list. + nifs: [ + %{module: MobBluetooth.Nif, native_dir: "priv/native/jni"} + ], + + android: %{ + # Merged into android/app/build.gradle's dependencies block. + gradle_deps: [], + + # Merged into AndroidManifest.xml. REQUIRES explicit user opt-in + # via `config :mob, :plugins` — mob_dev refuses to merge these + # silently for plugins that haven't been activated. + permissions: [ + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN" + ], + + # Kotlin file injected into MobBridge.kt's plugin extension slot. + # The plugin's Kotlin code declares its own BroadcastReceivers, + # external function bindings, etc. + bridge_kt: "priv/native/android/MobBluetoothBridge.kt", + + # C/Zig source compiled alongside beam_jni.c. Provides the JNI + # thunks that route into the plugin's NIFs. + jni_source: "priv/native/android/jni/bluetooth.c" + }, + + ios: %{ + # Swift files compiled with the project's existing swiftc invocation. + swift_files: ["priv/native/ios/MobBluetooth.swift"], + + # Info.plist keys to merge. iOS rejects builds without these for + # the matching permission categories — same opt-in gate as Android. + plist_keys: %{ + "NSBluetoothAlwaysUsageDescription" => + "Required by mob_bluetooth — replace this string in your Info.plist" + }, + + # System frameworks linked at the static-link step. + frameworks: ["CoreBluetooth"] + } +} +``` + +Notes: + +- `:gradle_deps` accept any string Gradle would understand (`group:artifact:version`). +- `:plist_keys` strings are placeholders — the user must replace them + in their `ios/Info.plist`. App Store review rejects apps with the + default text; this is intentional friction so the user provides a + real explanation. +- iOS or Android can be omitted. iOS-only and Android-only plugins + are valid. The validator warns (does not error) when one is missing + so users discover the gap. + +## Tier 2 — visual plugin + +Adds new render-tree node types. Same shape as tier 1 plus a +`:ui_components` section: + +```elixir +%{ + name: :mob_charts, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Line / bar / pie chart components", + + android: %{ + gradle_deps: ["com.github.PhilJay:MPAndroidChart:v3.1.0"] + }, + + ui_components: [ + %{ + # PascalCase tag for the ~MOB sigil: + tag: "Chart", + + # Snake-case atom for the render tree: %{type: :chart, ...} + atom: :chart, + + # Props the component accepts. Documentation + (eventually) + # compile-time validation. Optional today; required if you + # want `mix mob.routes` and similar tools to know the shape. + props: [:data, :type, :color, :width, :height], + + ios: %{ + # SwiftUI View struct in priv/native/ios/. mob's renderer + # dispatches `case .chart:` → `MobChartView(node: node)`. + view_module: "MobChartView" + }, + + android: %{ + # @Composable function in priv/native/android/. mob's + # renderer dispatches `"chart" -> MobChart(node, m)`. + composable: "MobChart" + } + }, + + %{ + tag: "Sparkline", + atom: :sparkline, + props: [:data, :color], + ios: %{view_module: "MobSparklineView"}, + android: %{composable: "MobSparkline"} + } + ] +} +``` + +A visual plugin can omit one platform if the component is genuinely +platform-specific (e.g., an iOS-only Live Activity widget). The +validator warns when a `ui_components` entry has only one platform — +silent UX bugs on the missing side are the #1 React Native plugin +pain point. + +**Visual plugins are NOT hot-pushable.** Adding a new node type +requires recompiling the native shell. The dev loop is "edit Elixir +→ rebuild app → reinstall," not "edit Elixir → `mix mob.push`." +The manifest validator surfaces this distinction. + +## Tier 3 — multi-screen plugin + +Plugins that ship entire screens (effectively mini-applications +embedded in the host). Adds `:screens`, `:migrations`, `:assets`: + +```elixir +%{ + name: :mob_in_app_purchase, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "StoreKit / Play Billing IAP flow", + + # ── tier-1 capability bits ── + nifs: [%{module: MobIap.Nif, native_dir: "priv/native/jni"}], + android: %{ + gradle_deps: ["com.android.billingclient:billing:6.1.0"], + bridge_kt: "priv/native/android/MobIapBridge.kt", + jni_source: "priv/native/android/jni/iap.c" + }, + ios: %{ + swift_files: ["priv/native/ios/MobIap.swift"], + frameworks: ["StoreKit"] + }, + + # ── tier-3 additions ── + + # Mob.Screen modules the plugin contributes. Host can push them + # via `Mob.UI.push_screen(MobIap.CatalogScreen)`. The plugin's + # README explains the intended navigation patterns. + screens: [ + %{module: MobIap.CatalogScreen, default_route: "/iap/catalog"}, + %{module: MobIap.CartScreen, default_route: "/iap/cart"}, + %{module: MobIap.ConfirmationScreen, default_route: "/iap/confirm"} + ], + + # Ecto migrations the plugin ships. The repo_namespace prefixes + # table names so plugins from different vendors don't collide. + # Host app's migrator picks them up at boot. + migrations: %{ + repo_namespace: "mob_iap_", + migrations_dir: "priv/repo/migrations" + }, + + # Asset bundles to merge into the host app's bundle. + # Fonts get registered automatically on iOS (UIAppFonts) and + # Android (assets/fonts/). Images are addressable from Mob.UI + # via "plugin://mob_iap/" path syntax. + assets: %{ + fonts: ["priv/assets/iap-icons.ttf"], + images: ["priv/assets/store-badge.png"] + } +} +``` + +The `screens:` section is declarative — it tells the host these +modules exist and provides suggested routes. The host app *chooses* +whether and where to wire them into its navigation. This avoids the +React-Native problem of plugins silently grabbing routes. + +## Tier 4 — embedded sub-app + +Tier 3 plus lifecycle hooks, settings, background workers, push +notifications. The line between "plugin" and "embedded application" +gets thin here — but as long as the plugin lives under the host's +supervisor (no independent OTP app), it's still a plugin. + +```elixir +%{ + name: :mob_chat_kit, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Embeddable chat (channels, messages, attachments)", + + # ... tier 1/2/3 fields ... + + lifecycle: %{ + # Called from Mob.App.on_start/0 after the host's own setup. + # Returns :ok or {:error, reason} — error bubbles to host. + on_start: {MobChatKit, :start, []}, + + # Children added to the host's supervisor tree. Same shape as + # Supervisor.child_spec. Started after on_start succeeds. + supervised: [ + MobChatKit.MessageSync, + {MobChatKit.PresenceTracker, []} + ], + + # Optional OS-level callbacks. Called when the app foregrounds + # or backgrounds. Plugin can flush pending state, pause workers, etc. + on_resume: {MobChatKit, :on_resume, []}, + on_background: {MobChatKit, :on_background, []} + }, + + settings: %{ + # User-facing settings the plugin exposes. Persisted via + # Mob.Storage in the namespace `:mob_chat_kit`. Defaults are + # used until the user opens the editor_screen and saves. + schema: [ + %{key: :sound_on_message, type: :boolean, default: true}, + %{key: :default_channel, type: :string, default: "#general"}, + %{key: :sync_interval_seconds, type: :integer, default: 30} + ], + + # Mob.Screen module the host can push to let users edit. The + # plugin owns the screen's UX; the host just provides the + # entry point. + editor_screen: MobChatKit.SettingsScreen + }, + + notifications: %{ + # Push notification handler. The host's notification dispatcher + # checks each plugin's handler in registration order; first + # match wins. `match` is either a function or a map prefix. + handlers: [ + %{ + match: %{type: "chat_message"}, + handler: {MobChatKit.Notifications, :handle_message, 1} + } + ] + } +} +``` + +`:settings.schema` typed entries get free runtime validation via +Mob.Storage. The plugin reads its own settings with +`Mob.Plugin.get_setting(:mob_chat_kit, :default_channel)`. + +## Install + activation flow + +Two-step opt-in by design. + +### Step 1 — install (`deps + mix deps.get`) + +Standard Hex flow. The plugin is now resolvable; mob_dev sees it on +the next compile. + +```elixir +# mix.exs +defp deps do + [ + {:mob, "~> 0.6"}, + {:mob_haptic_extras, "~> 0.1"} + ] +end +``` + +```bash +mix deps.get +``` + +After this, `mix mob.plugins` lists the plugin as **installed but not +activated**. Its native code is NOT merged into the build. Its +permissions are NOT added to your manifest. This is deliberate — a +silent `mix deps.get` should never modify your app's permission set. + +### Step 2 — activation (explicit consent in `mob.exs`) + +```elixir +# mob.exs +config :mob, :plugins, [ + :mob_haptic_extras, + :mob_bluetooth +] +``` + +Now mob_dev's compile step merges contributions. If `mob_bluetooth` +declares `BLUETOOTH_CONNECT` + `BLUETOOTH_SCAN`, those permissions +get added to `AndroidManifest.xml` only after the plugin is in this +list. mob_dev prints the diff at compile time so you see exactly +what's being added. + +If you've added a plugin to `deps` but not to `config :mob, +:plugins`, the next compile prints: + +``` +[mob] :mob_bluetooth is installed but not activated. Add it to + `config :mob, :plugins` in mob.exs to enable its contributions + (NIFs, permissions, native code). +``` + +### Convenience — `mix mob.add_plugin ` + +Wraps both steps + runs the plugin's interactive setup (if any): + +```bash +mix mob.add_plugin mob_chat_kit +``` + +Does: add to `deps`, run `mix deps.get`, add to `config :mob, +:plugins`, walk the plugin's `setup:` prompts (e.g., "Register +MobChatKit.MessageListScreen in your App.navigation/1? [Y/n]"). For +tier 1-2 plugins the prompts are usually empty. For tier 3-4 plugins +they're where the plugin author guides integration. + +Standard flow always works — `mix mob.add_plugin` is convenience, +not a required entry point. + +## Schema reference + +Top-level required: + +- `:name` — atom matching the package name. Convention: `mob_` prefix. +- `:mob_version` — string, semver requirement (`"~> 0.6"`). +- `:plugin_spec_version` — integer. Current: `1`. Bumped when this + schema makes breaking changes; old plugins keep working against + old spec versions. + +Top-level optional: + +- `:description` — short string for `mix mob.plugins` output. + +Capability sections (any combination): + +- `:nifs` — list of NIF declarations. See tier 1 example. +- `:android` — map of Android-specific contributions: + - `:gradle_deps` (list of strings) + - `:permissions` (list of strings — opt-in via activation) + - `:bridge_kt` (path to Kotlin file) + - `:jni_source` (path to C/Zig file) + - `:min_sdk` (integer, optional override) +- `:ios` — map of iOS-specific contributions: + - `:swift_files` (list of paths) + - `:plist_keys` (map — opt-in via activation) + - `:frameworks` (list of strings) + - `:min_version` (string, optional override) + +Visual sections: + +- `:ui_components` — list of component maps. Each entry: + - `:tag` (PascalCase string for the sigil) + - `:atom` (snake_case atom for the render tree) + - `:props` (list of atom keys, optional) + - `:ios` (map: `:view_module` SwiftUI struct name) + - `:android` (map: `:composable` function name) + +Multi-screen sections: + +- `:screens` — list of `%{module, default_route}` maps +- `:migrations` — `%{repo_namespace, migrations_dir}` map +- `:assets` — `%{fonts, images}` map + +Sub-app sections: + +- `:lifecycle` — `%{on_start, supervised, on_resume, on_background}` map +- `:settings` — `%{schema, editor_screen}` map +- `:notifications` — `%{handlers}` map + +Setup section (tier 3+): + +- `:setup` — list of interactive prompts that `mix mob.add_plugin` + walks through. Optional; mostly for tier-3/4 plugins. + +## Validation rules + +`mix mob.validate_plugin` (run from a plugin project) checks: + +- Required top-level fields present +- `mob_version` is a valid version requirement +- Every path in the manifest exists on disk +- Files declared as `bridge_kt` / `jni_source` / `swift_files` / + `view_module` / `composable` exist and parse +- `ui_components` entries with only one platform (warning, not error) +- `permissions` and `plist_keys` declared (warning + manual review + recommended before publishing) +- `mob_version` satisfied by the version of `:mob` in deps + +Compile-time validation (run by mob_dev when activating plugins): + +- Plugin's `mob_version` requirement satisfied by the installed mob +- No two activated plugins declare the same `ui_components.atom` +- No two activated plugins claim the same `screens.default_route` +- Migration `repo_namespace` doesn't collide with host or other plugins +- All plugins in `config :mob, :plugins` are present in `deps` + +Both stages fail loud — never silent. + +## Versioning and forward compatibility + +`:plugin_spec_version` is the escape hatch for evolving the schema +without breaking existing plugins. + +- Today: spec version 1. All examples above target spec 1. +- If the schema needs a breaking change (e.g., renaming `:ui_components` + to `:components`), bump to spec 2 and have mob_dev support both. +- Plugins declare which spec they target; mob_dev validates against + that spec; old plugins keep compiling unchanged. + +Bumping spec version means giving plugin authors a migration window +before deprecating the old spec. + +## Hot-push compatibility + +| Plugin tier | Hot-pushable? | Why | +|--|--|--| +| 0 (regular Hex pkg) | Yes | Pure Elixir; `.beam` ships via `mix mob.push` | +| 1 (NIFs) | No | Native code requires APK/IPA rebuild | +| 2 (visual component) | No | Same | +| 3 (multi-screen) | Partial — Elixir code in screens IS hot-pushable; native code IS NOT | +| 4 (sub-app) | Partial — same | + +The manifest validator computes `hot_pushable` automatically from +which sections are populated. Plugin docs should make this explicit +so users understand why some changes need a rebuild. + +## Why this design + +A few choices to flag: + +- **Manifest is data, not code.** The plugin doesn't `register_plugin` + at runtime; mob_dev reads the data at compile time. Static, + inspectable, validatable. Closer to `mix.exs`'s `project/0` than + to Phoenix's runtime route registration. +- **Two-step activation (deps + config).** Borrowed from how iOS + entitlements work — a framework supporting capability X doesn't + mean your app uses X; that requires explicit declaration. Mitigates + the supply-chain risk of silent permission merges. +- **Schema scales with tier, not exhaustive everywhere.** A tier-1 + plugin doesn't fill out `:lifecycle` or `:settings`. The schema + doesn't make small plugins look big. +- **Hex is the substrate.** Versioning, dep resolution, security + posture, hexdocs publication — all free. Local `path:` deps work + the same way for development. +- **Static-link required, no dlopen.** Mob's App-Store-compatible + build pins this. Plugins follow the same rule; the build embeds + plugin NIFs into the host's `libpigeon.so`. Restrictive vs. React + Native; necessary for App Store shipping. From b9743988d5ab06866b78b3cf2671ee311a2379b8 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 17 May 2026 17:31:05 -0600 Subject: [PATCH 090/254] =?UTF-8?q?Bump=20to=200.6.7=20=E2=80=94=20Mobile?= =?UTF-8?q?=20Surface=20Matrix=20+=20plugin=20spec=20ride-along?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.6.7 release ships two new docs to hexdocs.pm: - guides/mobile_surface_matrix.md — comprehensive audit of mob's mobile capability surface vs. React Native + Expo. Per-row status (✅/🟡/❌/⛔) with iOS + Android indicators across UI, gestures, device/system, storage, camera/audio, connectivity, sensors, location, notifications, background, auth/payment, ML/Vision, maps, accessibility, plus iOS-only and Android-only sections. Sets honest expectations and surfaces plugin candidates. - MOB_PLUGINS.md (already committed in e7b6402, riding along) — five-tier plugin manifest spec + install/activation flow + schema reference. Referenced from the matrix's ❌ rows. Linked from the README and added to mix.exs docs.extras so both are discoverable. Pure docs release — no code changes. Bump per the RELEASE.md rule: doc improvements warrant a bump so they reach hexdocs. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 8 + README.md | 6 + guides/mobile_surface_matrix.md | 343 ++++++++++++++++++++++++++++++++ mix.exs | 3 +- 4 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 guides/mobile_surface_matrix.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 056e826a..ca845233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [0.6.7] + +### Added +- `guides/mobile_surface_matrix.md` — comprehensive audit of mob's mobile capability surface vs. React Native + Expo SDK reference. Tables across UI components, gestures/input, device/system, storage, camera/audio, connectivity, sensors, location, notifications, background tasks, auth/payment, ML/Vision, maps, accessibility, iOS-only, Android-only, plus an "architecturally not present" section. Per-row status (✅ / 🟡 / ❌ / ⛔) with iOS + Android indicators. Hand-maintained from inspection of `lib/mob/` and `src/mob_nif.erl`. Sets realistic expectations and surfaces plugin candidates. +- README link + hexdocs entry so the matrix is discoverable for new users. +- `RELEASE.md` "Tests + docs for new functionality" section now includes a `mix docs` preview step and clarifies that hexdocs publishing is automatic via `mix hex.publish` (rides along from the previously-unreleased doc improvement). +- `MOB_PLUGINS.md` — plugin manifest schema spec covering five plugin tiers (pure Elixir helper through embedded sub-app), worked examples per tier, install + activation flow, schema reference, validation rules, hot-push compatibility table, plugin_spec_version forward-compat. References from the matrix's ❌ rows as plugin candidates. + ## [0.6.6] ### Added diff --git a/README.md b/README.md index 534c8b77..1dd03e41 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ def handle_info({:push_token, :ios, token}, socket), do: ... Also: `Mob.Clipboard`, `Mob.Share`, `Mob.Photos`, `Mob.Files`, `Mob.Audio`, `Mob.Motion`, `Mob.Biometric`, `Mob.Scanner`, `Mob.Permissions`. +For a full audit of what mob covers vs. what's missing vs. what's +out of scope (compared against React Native + Expo SDK capabilities), +see the [Mobile Surface Matrix](https://hexdocs.pm/mob/mobile_surface_matrix.html). +Set realistic expectations before starting an app; spot plugin +candidates if you want to fill a gap. + ## What's in the box The pre-built OTP runtime that ships with each app includes: diff --git a/guides/mobile_surface_matrix.md b/guides/mobile_surface_matrix.md new file mode 100644 index 00000000..202bb531 --- /dev/null +++ b/guides/mobile_surface_matrix.md @@ -0,0 +1,343 @@ +# Mobile surface matrix + +What Mob covers — what's solid, what's partial, what's missing. Use +this to set realistic expectations before starting an app, and to +spot gaps worth filling (either in mob core, in a plugin, or by +declaring out-of-scope). + +The reference surface is the union of **React Native core**, +**Expo SDK modules**, and platform-native capabilities both ecosystems +have converged on as "what mobile apps need." Many missing items are +**pluggable** — see [MOB_PLUGINS.md](../MOB_PLUGINS.md) for the +manifest spec. + +This doc is hand-maintained from inspection of `lib/mob/` and +`src/mob_nif.erl`. If you add a capability, update the matching row. + +## Legend + +| | | +|--|--| +| ✅ | Fully present — public Elixir API, both iOS + Android (unless noted) | +| 🟡 | Partial — works but limited (single platform, narrow API, or known caveats) | +| ❌ | Missing — could be a plugin or future core addition | +| ⛔ | Out of scope — requires separate deployment target (widgets, Watch app), or fundamentally incompatible with Mob's architecture | + +Per-platform columns: `✓` = supported, `—` = not supported, `n/a` = not applicable on that platform. + +--- + +## UI components (render tree) + +Elements you can use inside `~MOB`. The set is intentionally small +and orthogonal — composition over a fat component library. + +| Component | Status | iOS | Android | Notes | +|--|--|--|--|--| +| `` | ✅ | ✓ | ✓ | Container with align, padding, background, corner radius, border | +| ``, `` | ✅ | ✓ | ✓ | Flex layouts | +| `` | ✅ | ✓ | ✓ | Font, color, size, weight, align, line height, letter spacing | +| `