Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion android/jni/mob_nif.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 },
Expand Down
49 changes: 49 additions & 0 deletions decisions/2026-07-04-torch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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 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.
1 change: 1 addition & 0 deletions guides/mobile_surface_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions ios/mob_nif.m
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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},
Expand Down
50 changes: 50 additions & 0 deletions lib/mob/torch.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/mob_nif.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,6 +137,7 @@
exit_app/0,
safe_area/0,
haptic/1,
torch/1,
clipboard_put/1,
clipboard_get/0,
share_text/1,
Expand Down Expand Up @@ -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).
Expand Down
22 changes: 22 additions & 0 deletions test/mob/torch_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading