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
3 changes: 3 additions & 0 deletions android/jni/mob_beam.h
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,6 +100,9 @@ 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_motion(jlong pid, double ax, double ay, double az, double gx, double gy, double gz,
long long ts);
void mob_deliver_motion_mag(jlong pid, double ax, double ay, double az, double gx, double gy,
double gz, double mx, double my, double mz, double heading,
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);
Expand Down
4 changes: 4 additions & 0 deletions android/jni/mob_erts.zig
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,10 @@ pub extern fn enif_get_int(env: ?*ErlNifEnv, term: ERL_NIF_TERM, ip: *c_int) c_i
/// 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;

/// Split a list term into its head (car) and tail (cdr). Returns 1 while there
/// is a cell to read, 0 at the empty-list tail — the standard iteration idiom.
pub extern fn enif_get_list_cell(env: ?*ErlNifEnv, list: ERL_NIF_TERM, head: *ERL_NIF_TERM, tail: *ERL_NIF_TERM) c_int;

// ── Convenience wrappers ──────────────────────────────────────────────────
// Idiomatic Zig surface over the bare extern fns. Keeps NIF bodies tight.

Expand Down
95 changes: 92 additions & 3 deletions android/jni/mob_nif.zig
Original file line numberDiff line numberDiff line change
Expand Up@@ -2272,6 +2272,70 @@ pub export fn mob_deliver_motion(
_ = erts.enif_send(null, &pid, env, msg);
}

/// Like `mob_deliver_motion` but with the magnetometer field (µT) and a fused
/// heading. Sentinels for "no reading": `heading < 0` and a NaN `mag` component
/// are each delivered as the atom `nil` (RFC: magnetic north, degrees [0,360)).
/// This is the delivery used whenever `:magnetometer` was requested — even on a
/// device with no magnetometer, so the `mag`/`heading` keys are always present
/// (as `nil`) rather than absent. Emits the 5-key `{:motion, _}` map.
pub export fn mob_deliver_motion_mag(
jpid: jni.JLong,
ax: f64,
ay: f64,
az: f64,
gx: f64,
gy: f64,
gz: f64,
mx: f64,
my: f64,
mz: f64,
heading: 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 mag = if (std.math.isNan(mx) or std.math.isNan(my) or std.math.isNan(mz))
erts.atom(env, "nil")
else
erts.makeTuple(env, .{
erts.enif_make_double(env, mx),
erts.enif_make_double(env, my),
erts.enif_make_double(env, mz),
});
const heading_term = if (heading >= 0.0)
erts.enif_make_double(env, heading)
else
erts.atom(env, "nil");
const keys = [_]erts.ERL_NIF_TERM{
erts.atom(env, "accel"),
erts.atom(env, "gyro"),
erts.atom(env, "mag"),
erts.atom(env, "heading"),
erts.atom(env, "timestamp"),
};
const vals = [_]erts.ERL_NIF_TERM{
accel,
gyro,
mag,
heading_term,
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 {
Expand DownExpand Up@@ -2681,6 +2745,22 @@ export fn nif_audio_set_volume(
return erts.ok(env);
}

/// True if `list` (a list of sensor-name binaries) contains `name`. Used to
/// plumb the requested sensor set through to the Kotlin bridge, which otherwise
/// only sees the interval.
fn motionSensorRequested(env: ?*erts.ErlNifEnv, list: erts.ERL_NIF_TERM, name: []const u8) bool {
var cur = list;
var head: erts.ERL_NIF_TERM = undefined;
var tail: erts.ERL_NIF_TERM = undefined;
while (erts.enif_get_list_cell(env, cur, &head, &tail) != 0) {
var bin: erts.ErlNifBinary = undefined;
if (erts.enif_inspect_binary(env, head, &bin) != 0 and
std.mem.eql(u8, bin.data[0..bin.size], name)) return true;
cur = tail;
}
return false;
}

export fn nif_motion_start(
env: ?*erts.ErlNifEnv,
argc: c_int,
Expand All@@ -2689,11 +2769,20 @@ export fn nif_motion_start(
_ = 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 {};
// Encode the sensor request into the spec string the Kotlin bridge parses:
// "<interval>" or "<interval>,magnetometer". Android's motion_start only had
// the interval before, so it registered the magnetometer whenever the
// hardware existed — regardless of what the app asked for. Passing the flag
// lets it honor the request (and keep the plain accel/gyro stream plain).
const want_mag = motionSensorRequested(env, argv[0], "magnetometer");
var spec_buf: [32]u8 = @splat(0);
if (want_mag)
_ = std.fmt.bufPrint(&spec_buf, "{d},magnetometer", .{interval_ms}) catch {}
else
_ = std.fmt.bufPrint(&spec_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));
return callBridgePidStr(env, Bridge.motion_start, pid, jni.asCStr(&spec_buf));
}

export fn nif_motion_stop(
Expand Down
48 changes: 48 additions & 0 deletions decisions/2026-07-02-magnetometer-compass.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
# Magnetometer / compass support in Mob.Motion

- Date: 2026-07-02
- Status: accepted; partially superseded by `2026-07-04-magnetometer-stable-key-contract.md` (the "Android registers whenever hardware present (v1)" activation trigger and the map-shape/`nil`-key contract — the magnetic-north scope, additive keys, and delivery-via-`mob_deliver_motion_mag` decisions still stand)
- Issue: MOB-6

## Context

`Mob.Motion` exposed accelerometer + gyroscope but not the magnetometer, so a mob
app couldn't build a compass/heading. The sensor is present on most (not all)
phones. This adds it, cross-repo (`mob` Elixir + iOS + zig, `mob_new` Kotlin
template, per-app bridge regen).

## Decision

- **Report both `mag` (µT) and a fused `heading`** (degrees), not just the raw
field — a raw vector isn't a usable compass; heading needs sensor fusion, which
the platforms already do.
- **Magnetic north only.** True north needs location + geomagnetic declination —
out of scope; an app can layer it with `Mob.Location`.
- **iOS: opt-in via the sensor list.** When `:magnetometer` is requested and the
device supports the `XMagneticNorthZVertical` attitude reference frame, switch
device motion to that frame (fuses accel+gyro+mag → calibrated `magneticField` +
`heading` on one stream). Otherwise the plain accel/gyro stream is unchanged.
- **Android: register when the hardware is present** (v1), rather than threading
the sensor set through the JNI `motion_start` signature. Android already ignored
the sensor list (both accel+gyro always registered), so this matches existing
behavior; heading comes from `TYPE_ROTATION_VECTOR` → `getRotationMatrixFromVector`
→ `getOrientation`. **Follow-up:** make it opt-in (encode the sensor set in the
existing `motion_start` string arg — no ABI change) to avoid running the
magnetometer for accel-only consumers.
- **Delivery via a new `mob_deliver_motion_mag`** (5-key `{:motion, _}` map) rather
than widening `mob_deliver_motion` — keeps the existing accel/gyro path
byte-identical (zero risk to current consumers like the tilt-follow eyes).
- **`heading < 0` ⇒ `nil`.** Both platforms use a negative sentinel for
"unavailable"; the native layer converts it to the atom `nil`.

## Consequences

- `mag`/`heading` are **additive** map keys — existing accel/gyro consumers are
unaffected (map patterns aren't exclusive).
- Android v1 runs the magnetometer + rotation-vector whenever the hardware exists,
a small battery cost for accel-only users until the opt-in follow-up lands.
- iOS is opt-in; Android is present-if-hardware. The `heading`/`mag` contract is
identical; only the activation trigger differs (documented in `Mob.Motion`).
- The new delivery function must bind through the generated JNI thunk seam; the
per-app `MobBridge.kt` needs regenerating from the `mob_new` template (the same
bridge-refresh step every native addition needs).
68 changes: 68 additions & 0 deletions decisions/2026-07-04-magnetometer-stable-key-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
# Mob.Motion magnetometer: stable-key contract + Android opt-in

- Date: 2026-07-04
- Status: accepted
- Issue: MOB-6
- Amends: `2026-07-02-magnetometer-compass.md` (supersedes its "Android registers
when hardware present" and "heading nil sentinel only" points)

## Context

A review of the first magnetometer cut found the public `Mob.Motion` docstring
made two promises the implementation didn't keep:

1. "`heading` is `nil` on a device with no magnetometer." False — on both
platforms a magnetometer-less device fell back to the 3-key map, so the
`heading` key was **absent**, not `nil`. A compass app pattern-matching
`%{heading: h}` would hit a `KeyError` on exactly the phones this feature must
degrade gracefully on.
2. "`mag`/`heading` appear only when you request `:magnetometer`." True on iOS
(which parses the sensor list) but false on Android: `motion_start` only ever
received the interval, so it registered the magnetometer whenever the hardware
existed — regardless of the request. An accel/gyro-only consumer (e.g. the
tilt-follow eyes) on a magnetometer phone got surprise 5-key maps plus the
battery cost of two extra sensors.

The earlier ADR documented the Android asymmetry as an accepted v1 shortcut with
"make it opt-in" as a follow-up. We're doing the follow-up now rather than
shipping an inaccurate public contract.

## Decision

Make the map shape a function of **what was requested**, uniformly across
platforms:

- **Requested `:magnetometer` ⇒ `mag` + `heading` keys are always present**, each
`nil` when there's no reading (no magnetometer hardware, or heading not yet
fused). Stable keys — safe to pattern-match.
- **Did not request ⇒ neither key** (the plain 3-key accel/gyro stream, byte-
identical to before).

Mechanics:

- **Android sensor set is now plumbed through** without an ABI change: the
`motion_start/2` NIF still takes `(sensors, interval_ms)`, and `nif_motion_start`
(zig) encodes the request into the existing JNI string arg as
`"<interval>"` or `"<interval>,magnetometer"`. Kotlin parses it, registers the
magnetometer + rotation-vector **only when requested**, and picks the delivery
accordingly.
- **`nil` sentinels ride the existing 12-arg delivery.** `mob_deliver_motion_mag`
now maps a NaN `mag` component → `mag: nil` (in addition to the existing
`heading < 0` → `heading: nil`). So "requested but no hardware" still uses the
5-key delivery, passing NaN/−1, and the app sees `mag: nil, heading: nil`. No
new FFI symbol.
- **iOS** already knew the request (`want_mag`); it now builds the 5-key map
whenever `want_mag`, filling `nil`/`nil` when the magnetic-north reference frame
isn't available, instead of dropping to the 3-key map.

## Consequences

- Public contract now matches the docstring on both platforms; the `KeyError`
trap is gone and accel/gyro-only apps are untouched (and pay nothing extra).
- The FFI arity and the accel/gyro-only C path are still byte-identical — the
change is additive (sentinel interpretation + a request flag in a string).
- Device-verified: happy path (real heading/mag) on moto g + iPhone SE; opt-in
(no request ⇒ 3-key, no mag sensors) on a physical device; `nil`/`nil`
requested-but-no-hardware path on the iOS simulator (which has no magnetometer).
- `NaN` is the "no mag reading" wire sentinel — callers never see it (the native
layer converts to `nil`); documented at the `mob_deliver_motion_mag` export.
Loading
Loading