From 7d9c79388a7dadbe788ef6be27586d669ec81243 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 4 Jul 2026 20:07:02 -0600 Subject: [PATCH 1/2] MOB-15: torch / flashlight support in core (Mob.Torch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Mob.Torch (on/1, off/1, set/2) toggles the rear-camera torch with no camera session and no permission, so it's a lightweight core capability alongside Mob.Haptic rather than part of mob_camera. - lib/mob/torch.ex — on/off API; pure state_atom/1 mapping (host-testable) - src/mob_nif.erl — torch/1 NIF declaration + stub - ios/mob_nif.m — nif_torch: AVCaptureDevice torchMode, hasTorch guard, setTorchModeOnWithLevel: at max; no-op without a torch - android/jni/mob_nif.zig — nif_torch -> MobBridge.torch(String) via the cached-method seam (Kotlin half is the paired mob_new change) - test/mob/torch_test.exs — state_atom mapping + boolean guard - decisions/2026-07-04-torch.md — core-vs-plugin + on/off-only rationale - guides/mobile_surface_matrix.md — Torch row On/off only for v1 (iOS supports brightness levels, Android setTorchMode is binary — level is a follow-up). Device verification pending (no torch on the simulator). Co-Authored-By: Claude Opus 4.8 (1M context) --- android/jni/mob_nif.zig | 24 +++++++++++++++- decisions/2026-07-04-torch.md | 47 +++++++++++++++++++++++++++++++ guides/mobile_surface_matrix.md | 1 + ios/mob_nif.m | 29 +++++++++++++++++++ lib/mob/torch.ex | 50 +++++++++++++++++++++++++++++++++ src/mob_nif.erl | 3 ++ test/mob/torch_test.exs | 22 +++++++++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 decisions/2026-07-04-torch.md create mode 100644 lib/mob/torch.ex create mode 100644 test/mob/torch_test.exs diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 33522eed..8c9bdfdf 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -24,7 +24,7 @@ //! The C-side `nif_load` calls `mob_nif_init_state` (exported here) //! to create the mutexes during BEAM init. //! * iter 3d (this iter): the finale. Remaining feature NIFs (color -//! scheme, exit_app, safe_area, haptic, clipboard, open_url, +//! scheme, exit_app, safe_area, haptic, torch, clipboard, open_url, //! share_text, launch notification, request_permission, //! files_pick, //! audio ×5, motion ×2, scanner, notifications ×3, storage ×4, @@ -237,6 +237,7 @@ pub const BridgeMethods = extern struct { get_safe_area: jni.JMethodID = null, get_color_scheme: jni.JMethodID = null, haptic: jni.JMethodID = null, + torch: jni.JMethodID = null, clipboard_put: jni.JMethodID = null, clipboard_get: jni.JMethodID = null, tts_speak: jni.JMethodID = null, @@ -1951,6 +1952,25 @@ export fn nif_haptic( return erts.ok(env); } +// nif_torch/1 — pass the atom `on` or `off` to MobBridge.torch(String), which +// toggles the rear-camera torch. No-op on a device without a flash unit. +export fn nif_torch( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var state_buf: [8]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &state_buf, state_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jstate = jni.newStringUTF(jenv, jni.asCStr(&state_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.torch, jstate); + jni.deleteLocalRef(jenv, jstate); + detachIfAttached(attached); + return erts.ok(env); +} + // nif_clipboard_put/1 — ClipboardManager.setPrimaryClip via Kotlin. export fn nif_clipboard_put( env: ?*erts.ErlNifEnv, @@ -3444,6 +3464,7 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c cacheOptional(jenv, "setTheme", "(Ljava/lang/String;)V", &Bridge.set_theme); if (!cacheRequired(jenv, "haptic", "(Ljava/lang/String;)V", &Bridge.haptic)) return -1; + if (!cacheRequired(jenv, "torch", "(Ljava/lang/String;)V", &Bridge.torch)) 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; // Optional: apps generated before TTS existed lack these MobBridge methods. @@ -3571,6 +3592,7 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .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 = "torch", .arity = 1, .fptr = nif_torch, .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 = "tts_speak", .arity = 2, .fptr = nif_tts_speak, .flags = 0 }, diff --git a/decisions/2026-07-04-torch.md b/decisions/2026-07-04-torch.md new file mode 100644 index 00000000..86d78f81 --- /dev/null +++ b/decisions/2026-07-04-torch.md @@ -0,0 +1,47 @@ +# Torch / flashlight in core (`Mob.Torch`) + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-15 + +## Context + +`Mob.Motion` gained the magnetometer (MOB-6); torch/flashlight was the next +Tier-2 hardware gap from the 2026-07-04 capability audit. The rear-camera torch +is high-utility and cheap to wrap. The open questions were **where it lives** +(core vs the `mob_camera` plugin) and **how rich the API is** (on/off vs +brightness levels). + +## Decision + +- **Core, not `mob_camera`.** Both platforms toggle the torch **without opening a + camera capture session and without the camera permission** — iOS via + `AVCaptureDevice.lockForConfiguration` + `torchMode`, Android via + `CameraManager.setTorchMode`. So torch has no dependency on the camera plugin + and belongs alongside the other lightweight, permission-free device outputs + like `Mob.Haptic`. A new `Mob.Torch` module mirrors that shape. +- **On/off only for v1.** iOS supports a brightness level + (`setTorchModeOnWithLevel:`) but Android `setTorchMode` is binary (per-torch + strength is API 33+ only, `turnOnTorchWithStrengthLevel`). A cross-platform + `level:` option would be honored on one side and clamped on the other, so it's + deferred to keep the v1 contract honest. v1 drives iOS at + `AVCaptureMaxAvailableTorchLevel`. +- **Fire-and-forget, no-op when absent.** `set/2` returns the socket unchanged + (like `Mob.Haptic.trigger/2`). A device with no flash unit (tablets, the iOS + simulator) is a **no-op, not an error** — both native sides guard on hardware + presence. The module does not read torch state back; the app owns the boolean. +- **Wire contract:** `:mob_nif.torch/1` takes the atom `on` | `off`. Same + command shape as `haptic/1`, so it reuses the established seam (iOS NIF array + entry; Android zig `CallStaticVoidMethod` on a cached `MobBridge.torch(String)` + method — the Kotlin bridge half is the paired `mob_new` change). + +## Consequences + +- Cross-repo, one issue: `mob` (Elixir + NIF + iOS + zig) plus `mob_new` (the + generated `MobBridge.torch` Kotlin method). Existing apps pick it up by + regenerating their bridge + depending on the mob release that carries the NIF. +- The pure `Mob.Torch.state_atom/1` mapping is host-unit-tested; the native + toggle itself needs on-hardware verification (no torch on the simulator) — + pending, flagged like MOB-6 was. +- Brightness-level control and a `hardware present?` query are the natural + follow-ups if demand appears. diff --git a/guides/mobile_surface_matrix.md b/guides/mobile_surface_matrix.md index f3b36231..364a5b60 100644 --- a/guides/mobile_surface_matrix.md +++ b/guides/mobile_surface_matrix.md @@ -137,6 +137,7 @@ and orthogonal — composition over a fat component library. | Voice activity detection | ❌ | — | — | Plugin candidate | | Audio effects (reverb, EQ) | ❌ | — | — | Plugin candidate | | Camera zoom / focus / exposure | 🟡 | 🟡 | 🟡 | Basic capture works; fine-grained control missing | +| Torch / flashlight | ✅ | ✓ | ✓ | `Mob.Torch.on/1`, `off/1`, `set/2` — core, no camera session or permission. On/off only (brightness level is a follow-up) | ## Connectivity diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 42d1dc0c..1b0387a0 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -1923,6 +1923,34 @@ static ERL_NIF_TERM nif_haptic(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv return enif_make_atom(env, "ok"); } +// ── NIF: torch/1 ────────────────────────────────────────────────────────────── +// Toggle the rear-camera torch. argv[0] is the atom `on` or `off`. No capture +// session and no camera permission needed. No-op (not an error) on a device +// without a torch — the simulator and most tablets have none. +static ERL_NIF_TERM nif_torch(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char state[8] = {0}; + enif_get_atom(env, argv[0], state, sizeof(state), ERL_NIF_LATIN1); + BOOL on = (strcmp(state, "on") == 0); + + dispatch_async(dispatch_get_main_queue(), ^{ + AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!device || !device.hasTorch || !device.isTorchAvailable) + return; + NSError *err = nil; + if (![device lockForConfiguration:&err]) + return; + if (on) { + // setTorchModeOnWithLevel: validates the level and is preferred over + // the torchMode setter; max level = full brightness. + [device setTorchModeOnWithLevel:AVCaptureMaxAvailableTorchLevel error:NULL]; + } else { + device.torchMode = AVCaptureTorchModeOff; + } + [device unlockForConfiguration]; + }); + return enif_make_atom(env, "ok"); +} + // ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── // Writes a UTF-8 binary to the system clipboard. Fire-and-forget. @@ -6017,6 +6045,7 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"exit_app", 0, nif_exit_app, 0}, {"safe_area", 0, nif_safe_area, 0}, {"haptic", 1, nif_haptic, 0}, + {"torch", 1, nif_torch, 0}, {"clipboard_put", 1, nif_clipboard_put, 0}, {"clipboard_get", 0, nif_clipboard_get, 0}, {"share_text", 1, nif_share_text, 0}, diff --git a/lib/mob/torch.ex b/lib/mob/torch.ex new file mode 100644 index 00000000..e47457ea --- /dev/null +++ b/lib/mob/torch.ex @@ -0,0 +1,50 @@ +defmodule Mob.Torch do + @moduledoc """ + Rear-camera torch (flashlight) on/off. No permission required on either + platform — the torch is toggled directly, without opening a camera session. + + ## Usage + + def handle_event("toggle_light", _params, socket) do + on? = not socket.assigns.light_on + {:noreply, socket |> Mob.Torch.set(on?) |> assign(:light_on, on?)} + end + + `on/1` and `off/1` are conveniences over `set/2`. + + On a device with **no rear flash** (most tablets, the iOS simulator) this is a + **no-op, not an error** — check the hardware yourself if you need to hide the + control. The torch is a shared hardware resource: the OS turns it off when the + app is backgrounded, and an active camera capture can override it. This module + is fire-and-forget and does not read the state back — the app owns the on/off + boolean and should re-assert it after resuming if it needs to persist. + + iOS: `AVCaptureDevice.torchMode` via `lockForConfiguration`. Android: + `CameraManager.setTorchMode` on the rear camera that reports a flash unit. + """ + + @doc "Turn the torch on. Returns the socket unchanged." + @spec on(Mob.Socket.t()) :: Mob.Socket.t() + def on(socket), do: set(socket, true) + + @doc "Turn the torch off. Returns the socket unchanged." + @spec off(Mob.Socket.t()) :: Mob.Socket.t() + def off(socket), do: set(socket, false) + + @doc """ + Set the torch on (`true`) or off (`false`). Returns the socket unchanged so it + can be used inline in a `handle_event`/`handle_info` return. + """ + @spec set(Mob.Socket.t(), boolean()) :: Mob.Socket.t() + def set(socket, on?) when is_boolean(on?) do + :mob_nif.torch(state_atom(on?)) + socket + end + + @doc false + # The wire atom the NIF expects for a given on/off boolean. Public (hidden) so + # the mapping is unit-testable without a loaded NIF. + @spec state_atom(boolean()) :: :on | :off + def state_atom(true), do: :on + def state_atom(false), do: :off +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 883e2b20..377a9c42 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -14,6 +14,7 @@ safe_area/0, %% Device utilities (no permission required) haptic/1, + torch/1, clipboard_put/1, clipboard_get/0, share_text/1, @@ -136,6 +137,7 @@ exit_app/0, safe_area/0, haptic/1, + torch/1, clipboard_put/1, clipboard_get/0, share_text/1, @@ -254,6 +256,7 @@ 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). +torch(_State) -> 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). diff --git a/test/mob/torch_test.exs b/test/mob/torch_test.exs new file mode 100644 index 00000000..35e5edfd --- /dev/null +++ b/test/mob/torch_test.exs @@ -0,0 +1,22 @@ +defmodule Mob.TorchTest do + use ExUnit.Case, async: true + + # set/2 calls into the NIF (unavailable on the host), so we test the pure + # wire-atom mapping that decides what the native layer receives, plus the + # boolean guard. + describe "state_atom/1" do + test "true maps to :on" do + assert Mob.Torch.state_atom(true) == :on + end + + test "false maps to :off" do + assert Mob.Torch.state_atom(false) == :off + end + end + + describe "set/2 guard" do + test "rejects a non-boolean before reaching the NIF" do + assert_raise FunctionClauseError, fn -> Mob.Torch.set(%{}, :yes) end + end + end +end From bc7d6227cc4ae0a42b9984fa9f8a53cb10acfaea Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 4 Jul 2026 21:19:16 -0600 Subject: [PATCH 2/2] MOB-15: mark torch device-verified (moto g + iPhone SE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both platforms physically lit the rear flash on and off — Android via :mob_nif.torch/1 over dist, iOS via an on-device Mob.Torch.set/2 button. Co-Authored-By: Claude Opus 4.8 (1M context) --- decisions/2026-07-04-torch.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/decisions/2026-07-04-torch.md b/decisions/2026-07-04-torch.md index 86d78f81..d989e20d 100644 --- a/decisions/2026-07-04-torch.md +++ b/decisions/2026-07-04-torch.md @@ -41,7 +41,9 @@ brightness levels). generated `MobBridge.torch` Kotlin method). Existing apps pick it up by regenerating their bridge + depending on the mob release that carries the NIF. - The pure `Mob.Torch.state_atom/1` mapping is host-unit-tested; the native - toggle itself needs on-hardware verification (no torch on the simulator) — - pending, flagged like MOB-6 was. + toggle is **device-verified** on real hardware (no torch on the simulator): + moto g power (2021) via `:mob_nif.torch(:on|:off)` over dist, and iPhone SE + (3rd gen) via an on-device `Mob.Torch.set/2` button — both physically lit the + rear flash on and off. - Brightness-level control and a `hardware present?` query are the natural follow-ups if demand appears.