diff --git a/AGENTS.md b/AGENTS.md
index c2983ec..2c25712 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -595,10 +595,18 @@ These are the things we've burned ourselves on. Following them isn't optional.
can route a callback from an old native tree into the current screen. A sender
must also copy the tag into its delivery environment while holding that
mutex; the table's `tag_env` may be freed as soon as the lock is released.
- Change events and animation-delayed dismissals may cross one render when
- both retained registrations have identical PID and tag identity; taps and
- gestures stay generation-strict. Invalidate the building table's generation
- at `clear_taps` so stale lookup never observes a partially rebuilt table.
+ Change events, animation-delayed dismissals, press in/out, and taps on a
+ node that also declares `on_press_in` / `on_press_out` may cross renders
+ when both retained registrations have identical PID and tag identity; plain
+ taps and the other gestures stay generation-strict. A plain tap must stay
+ strict because a positional tag (Mob.List's `{:select, id, index}`) from an
+ old tree would select a different row after the list changes. A press
+ node re-renders between a finger's down and up (`on_press_in`), so its tap
+ goes through the tolerant `mob_send_press_tap`. A press_out's routing is
+ copied at touch-down and sent from the copy, so the pair survives any number
+ of renders (`decisions/2026-10-03-press-in-out-and-held-press.md`).
+ Invalidate the building table's generation at `clear_taps` so stale lookup
+ never observes a partially rebuilt table.
4. **TDD discipline in mob_dev.** Every new public function gets a test.
`mob_dev/AGENTS.md` makes this explicit. Don't bypass — the tests are how we
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aa1054..7bce526 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,33 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob).
---
+## [Unreleased]
+
+### Added
+- **`on_press_in` / `on_press_out` on any node** (MOB-380): `{:press_in, tag}`
+ when a finger goes down on the node, `{:press_out, tag}` when it lifts or the
+ touch is cancelled. Always paired, even across the re-render a press usually
+ causes: native resolves both handles at touch-down and keeps press_out's
+ routing until the lift. They observe without consuming, so `on_tap`,
+ `on_long_press` and an ancestor's scrolling keep working on the same node,
+ `button` included. iOS (SwiftUI) and Android (Compose; the observer lives in
+ the generated bridge, so it needs `mob_new` 0.6.5 or newer). For
+ hold-to-talk and anything else keyed on a held finger. The `on_tap` of a
+ node that declares either prop goes through the new `mob_send_press_tap`,
+ which accepts a handle from the render before when its slot still holds the
+ same pid and tag, so the re-render a press causes no longer drops the tap.
+ Plain taps stay generation-strict.
+- **`Mob.Test` can hold a press** (MOB-380). `press_down_xy/4`,
+ `press_move_xy/3`, `press_up_xy/3` and `hold_xy/4` put a real finger on an
+ Android app's window and keep it there across calls, so a Compose
+ `detectTapGestures` `onPress` sits in `tryAwaitRelease()` while the test
+ reads state (`adb input swipe x y x y 3000` does not produce that on every
+ device). A held press auto-cancels after `:max_hold_ms` (30 s); other
+ synthetic gestures refuse while it is down. New NIFs
+ `mob_nif:press_down_xy/3`, `press_move_xy/2`, `press_up_xy/2`, reported by
+ `capabilities/1`; iOS answers `{:error, :not_supported}`. By-tag
+ `press_in/2`, `press_out/2` and `hold/3` drive a screen on every platform.
+
## [0.9.11] - 2026-10-03
### Upgrading
diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h
index 320289c..4199df0 100644
--- a/android/jni/mob_beam.h
+++ b/android/jni/mob_beam.h
@@ -16,6 +16,12 @@ void mob_ui_cache_class(JNIEnv *env, const char *bridge_class);
// Called from the app's Java_..._MobBridge_nativeSendTap JNI stub.
void mob_send_tap(int handle);
+// Send a tap from a node that also declares on_press_in / on_press_out
+// (MOB-380). Identity-tolerant: a handle from the previous render still
+// resolves when its slot holds the same pid and tag (press_in usually
+// re-renders before the lift). Called from Java_..._MobBridge_nativeSendPressTap.
+void mob_send_press_tap(int handle);
+
// Send a {:dismiss, tag} event — the shape Mob.UI.sheet/2 documents for
// :on_dismiss, matching iOS. Called from the app's
// Java_..._MobBridge_nativeSendDismiss JNI stub.
@@ -53,6 +59,12 @@ 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);
+// on_press_in / on_press_out (MOB-380). Begin resolves both handles at
+// touch-down (-1 for one the node doesn't declare), snapshots press_out's
+// routing, then sends {:press_in, tag}; returns the token for mob_press_end, or
+// -1 when there is no press_out to deliver. End sends {:press_out, tag} once.
+int mob_press_begin(int in_handle, int out_handle);
+void mob_press_end(int token);
// ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ─────
// Throttling and delta-thresholding are applied native-side BEFORE these
diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig
index 112709b..a76c649 100644
--- a/android/jni/mob_nif.zig
+++ b/android/jni/mob_nif.zig
@@ -292,6 +292,10 @@ pub const BridgeMethods = extern struct {
render_stats_enable: jni.JMethodID = null,
long_press_xy: jni.JMethodID = null,
swipe_xy: jni.JMethodID = null,
+ // MOB-380 held press: pressDownXy(FFJ)I, pressMoveXy(FF)I, pressUpXy(FF)I.
+ press_down_xy: jni.JMethodID = null,
+ press_move_xy: jni.JMethodID = null,
+ press_up_xy: jni.JMethodID = null,
screenshot: jni.JMethodID = null,
scroll_info: jni.JMethodID = null,
scroll_to: jni.JMethodID = null,
@@ -679,7 +683,8 @@ export fn nif_capabilities(
erts.atom(env, "ax_action"), erts.atom(env, "element_frames"),
erts.atom(env, "scroll_info"), erts.atom(env, "scroll_to"),
erts.atom(env, "sample_region"), erts.atom(env, "screenshot"),
- erts.atom(env, "native_stats"),
+ erts.atom(env, "native_stats"), erts.atom(env, "press_down_xy"),
+ erts.atom(env, "press_move_xy"), erts.atom(env, "press_up_xy"),
};
const vals = [_]erts.ERL_NIF_TERM{
boolAtom(env, Bridge.ui_view_tree != null),
@@ -711,6 +716,9 @@ export fn nif_capabilities(
// for the pair; renderStatsEnable without renderStats would be an app
// that half-applied a template update.
boolAtom(env, Bridge.render_stats != null),
+ boolAtom(env, Bridge.press_down_xy != null),
+ boolAtom(env, Bridge.press_move_xy != null),
+ boolAtom(env, Bridge.press_up_xy != null),
};
return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error");
}
@@ -1017,6 +1025,90 @@ export fn nif_swipe_xy(
return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed");
}
+// ── Held press (MOB-380) ─────────────────────────────────────────────────
+// press_down_xy/3, press_move_xy/2, press_up_xy/2: a finger that stays down
+// across calls. The bridge answers with a code rather than a boolean, since
+// "a press is already held" and "no press is held" are different mistakes
+// from "nothing took the touch". Codes mirror PRESS_* in MobBridge.kt.
+fn pressResult(env: ?*erts.ErlNifEnv, code: jni.JInt) erts.ERL_NIF_TERM {
+ return switch (code) {
+ 0 => erts.ok(env),
+ 1 => errorAtom(env, "dispatch_failed"),
+ 2 => errorAtom(env, "already_pressed"),
+ 3 => errorAtom(env, "no_window"),
+ 4 => errorAtom(env, "not_pressed"),
+ 5 => errorAtom(env, "window_gone"),
+ 6 => errorAtom(env, "timeout"),
+ else => errorAtom(env, "unknown"),
+ };
+}
+
+export fn nif_press_down_xy(
+ env: ?*erts.ErlNifEnv,
+ argc: c_int,
+ argv: [*]const erts.ERL_NIF_TERM,
+) callconv(.c) erts.ERL_NIF_TERM {
+ _ = argc;
+ if (Bridge.press_down_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 max_hold: c_int = 0;
+ if (erts.enif_get_int(env, argv[2], &max_hold) == 0 or max_hold <= 0) return erts.badarg(env);
+
+ var attached: c_int = 0;
+ const jenv = get_jenv(&attached) orelse return erts.atom(env, "error");
+ const code = jenv.*.CallStaticIntMethod.?(
+ jenv,
+ Bridge.cls,
+ Bridge.press_down_xy,
+ @as(f32, @floatCast(x)),
+ @as(f32, @floatCast(y)),
+ @as(i64, @intCast(max_hold)),
+ );
+ detachIfAttached(attached);
+ return pressResult(env, code);
+}
+
+fn pressAt(
+ env: ?*erts.ErlNifEnv,
+ argv: [*]const erts.ERL_NIF_TERM,
+ method: jni.JMethodID,
+) erts.ERL_NIF_TERM {
+ if (method == 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 code = jenv.*.CallStaticIntMethod.?(
+ jenv,
+ Bridge.cls,
+ method,
+ @as(f32, @floatCast(x)),
+ @as(f32, @floatCast(y)),
+ );
+ detachIfAttached(attached);
+ return pressResult(env, code);
+}
+
+export fn nif_press_move_xy(
+ env: ?*erts.ErlNifEnv,
+ argc: c_int,
+ argv: [*]const erts.ERL_NIF_TERM,
+) callconv(.c) erts.ERL_NIF_TERM {
+ _ = argc;
+ return pressAt(env, argv, Bridge.press_move_xy);
+}
+
+export fn nif_press_up_xy(
+ env: ?*erts.ErlNifEnv,
+ argc: c_int,
+ argv: [*]const erts.ERL_NIF_TERM,
+) callconv(.c) erts.ERL_NIF_TERM {
+ _ = argc;
+ return pressAt(env, argv, Bridge.press_up_xy);
+}
+
// ── Handle registries (Phase 6b iter 3c) ─────────────────────────────────
//
// Two pools of per-widget routing slots. The tap registry is cleared every
@@ -1152,6 +1244,7 @@ var component_mutex: ?*erts.ErlNifMutex = null;
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;
+ press_mutex = erts.enif_mutex_create("mob_press_mutex") orelse return -1;
return 0;
}
@@ -1280,11 +1373,24 @@ fn sendChange(handle: c_int, value_term: erts.ERL_NIF_TERM) void {
// ── Tap + change senders ────────────────────────────────────────────────
/// Called from beam_jni.c's `nativeSendTap` JNI stub. Sends `{:tap, tag}`
-/// to the pid registered for `handle`.
+/// to the pid registered for `handle`. Generation-strict: a handle from an
+/// earlier render is dropped, so a positional tag (Mob.List's
+/// `{:select, id, index}`) never reaches a row that moved under it.
pub export fn mob_send_tap(handle: c_int) callconv(.c) void {
sendEvent(handle, "tap");
}
+/// Called from beam_jni.c's `nativeSendPressTap` JNI stub, which the bridge
+/// uses instead of `nativeSendTap` for a node that also declares on_press_in
+/// or on_press_out (MOB-380). Such a node re-renders between the finger's
+/// down and up (press_in usually changes the screen), so its lift fires a
+/// handle from the previous render. Identity-tolerant, like change events:
+/// it still resolves when the slot holds the same pid and tag. Plain taps
+/// stay on the strict `mob_send_tap`.
+pub export fn mob_send_press_tap(handle: c_int) callconv(.c) void {
+ sendIdentityEvent(handle, "tap");
+}
+
/// Called from beam_jni.c's `nativeSendDismiss` JNI stub. Sends
/// `{:dismiss, tag}` — the shape `Mob.UI.sheet/2` documents for `:on_dismiss`
/// and the one iOS has always delivered (`mob_send_dismiss` in ios/mob_nif.m).
@@ -1389,6 +1495,88 @@ pub export fn mob_send_swipe_down(handle: c_int) callconv(.c) void {
sendEvent(handle, "swipe_down");
}
+// ── Press in / out (MOB-380) ─────────────────────────────────────────────
+// A press_out has to reach the screen however many renders happen while the
+// finger is down, and the press itself usually causes one ("listening"). So
+// its routing is resolved at touch-down and kept here until the lift, rather
+// than looked up again from a handle that may be stale by then. Both handles
+// resolve identity-tolerantly, like change events: same slot, pid and tag
+// across renders. Ten slots: one per finger on a different node.
+const MAX_PRESSES: usize = 10;
+const PressSlot = struct {
+ env: ?*erts.ErlNifEnv = null,
+ pid: erts.ErlNifPid = undefined,
+ tag: erts.ERL_NIF_TERM = 0,
+};
+var press_slots: [MAX_PRESSES]PressSlot = @splat(PressSlot{});
+var press_mutex: ?*erts.ErlNifMutex = null;
+
+fn takePress(token: c_int) ?PressSlot {
+ if (token < 0 or token >= MAX_PRESSES) return null;
+ erts.enif_mutex_lock(press_mutex);
+ defer erts.enif_mutex_unlock(press_mutex);
+ const slot = press_slots[@intCast(token)];
+ if (slot.env == null) return null;
+ press_slots[@intCast(token)] = .{};
+ return slot;
+}
+
+/// Called at touch-down with the node's on_press_in and on_press_out handles
+/// (-1 for one it doesn't declare). Snapshots press_out first, then sends
+/// `{:press_in, tag}`; when a declared handle can't be resolved nothing is
+/// sent, so a press_out never arrives without its press_in. Returns the token
+/// for mob_press_end, or -1 when there is no press_out to deliver.
+pub export fn mob_press_begin(in_handle: c_int, out_handle: c_int) callconv(.c) c_int {
+ var token: c_int = -1;
+ if (out_handle >= 0) {
+ const keep = erts.enif_alloc_env() orelse return -1;
+ const snap = snapChangeTap(out_handle, keep) orelse {
+ erts.enif_free_env(keep);
+ return -1;
+ };
+ erts.enif_mutex_lock(press_mutex);
+ for (&press_slots, 0..) |*slot, i| {
+ if (slot.env == null) {
+ slot.* = .{ .env = keep, .pid = snap.pid, .tag = snap.tag };
+ token = @intCast(i);
+ break;
+ }
+ }
+ erts.enif_mutex_unlock(press_mutex);
+ if (token < 0) {
+ erts.enif_free_env(keep);
+ logd_nif("press slots exhausted; dropping press on handle {d}", .{out_handle});
+ return -1;
+ }
+ }
+
+ if (in_handle >= 0) {
+ const env = erts.enif_alloc_env() orelse {
+ if (takePress(token)) |slot| erts.enif_free_env(slot.env);
+ return -1;
+ };
+ defer erts.enif_free_env(env);
+ const snap = snapChangeTap(in_handle, env) orelse {
+ if (takePress(token)) |slot| erts.enif_free_env(slot.env);
+ return -1;
+ };
+ const msg = erts.makeTuple(env, .{ erts.enif_make_atom(env, "press_in"), snap.tag });
+ var pid = snap.pid;
+ _ = erts.enif_send(null, &pid, env, msg);
+ }
+ return token;
+}
+
+/// The finger lifted or the gesture was cancelled: `{:press_out, tag}` from
+/// the touch-down snapshot. A token is delivered at most once.
+pub export fn mob_press_end(token: c_int) callconv(.c) void {
+ const slot = takePress(token) orelse return;
+ defer erts.enif_free_env(slot.env);
+ const msg = erts.makeTuple(slot.env, .{ erts.enif_make_atom(slot.env, "press_out"), slot.tag });
+ var pid = slot.pid;
+ _ = erts.enif_send(null, &pid, slot.env, msg);
+}
+
pub export fn mob_send_swipe_with_direction(handle: c_int, direction: [*:0]const u8) callconv(.c) void {
const env = erts.enif_alloc_env() orelse return;
defer erts.enif_free_env(env);
@@ -5146,6 +5334,9 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c
cacheOptional(jenv, "clearText", "()Z", &Bridge.clear_text);
cacheOptional(jenv, "longPressXy", "(FFJ)Z", &Bridge.long_press_xy);
cacheOptional(jenv, "swipeXy", "(FFFF)Z", &Bridge.swipe_xy);
+ cacheOptional(jenv, "pressDownXy", "(FFJ)I", &Bridge.press_down_xy);
+ cacheOptional(jenv, "pressMoveXy", "(FF)I", &Bridge.press_move_xy);
+ cacheOptional(jenv, "pressUpXy", "(FF)I", &Bridge.press_up_xy);
cacheOptional(jenv, "renderStats", "()Ljava/lang/String;", &Bridge.render_stats);
cacheOptional(jenv, "renderStatsEnable", "(Z)Z", &Bridge.render_stats_enable);
@@ -5251,6 +5442,11 @@ const nif_funcs = [_]erts.ErlNifFunc{
.{ .name = "clear_text", .arity = 0, .fptr = nif_clear_text, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
.{ .name = "long_press_xy", .arity = 3, .fptr = nif_long_press_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
.{ .name = "swipe_xy", .arity = 4, .fptr = nif_swipe_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
+ // A held press dispatches one MotionEvent per call, but each still waits on
+ // the UI thread (and on another gesture holding the gesture mutex).
+ .{ .name = "press_down_xy", .arity = 3, .fptr = nif_press_down_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
+ .{ .name = "press_move_xy", .arity = 2, .fptr = nif_press_move_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
+ .{ .name = "press_up_xy", .arity = 2, .fptr = nif_press_up_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND },
.{ .name = "native_stats", .arity = 0, .fptr = nif_native_stats, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND },
.{ .name = "native_stats_enable", .arity = 1, .fptr = nif_native_stats_enable, .flags = 0 },
.{ .name = "screenshot", .arity = 3, .fptr = nif_screenshot, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND },
diff --git a/decisions/2026-10-03-press-in-out-and-held-press.md b/decisions/2026-10-03-press-in-out-and-held-press.md
new file mode 100644
index 0000000..ecd5b30
--- /dev/null
+++ b/decisions/2026-10-03-press-in-out-and-held-press.md
@@ -0,0 +1,96 @@
+# Press in / out props, and a held press agents can drive
+
+- Date: 2026-10-03
+- Status: accepted
+
+## Context
+
+Hold-to-talk (MOB-380) needs to know when a finger goes down on a node and
+when it comes up. Mob had `on_tap` and `on_long_press`, both of which fire
+once, at the end of a gesture, so Operator built its mic as an app-local
+Compose `native_view` around `detectTapGestures(onPress = { tryAwaitRelease() })`.
+
+Agents could not test any of it. On the Moto G 2021, `adb shell input swipe
+x y x y 3000` and `input motionevent DOWN/UP` never reached Compose as a held
+press, and `Mob.Test.long_press_xy/4` holds and releases inside one call, so
+nothing can look at the app while the finger is down.
+
+## Decision
+
+1. **`on_press_in` / `on_press_out`**, named after React Native's
+ `onPressIn` / `onPressOut` rather than `on_press` / `on_release`, because
+ "press" alone reads as a tap in mob's existing vocabulary. Messages are
+ `{:press_in, tag}` / `{:press_out, tag}`, with no payload: a handler that
+ wants the hold length measures it on the BEAM, where both events cross the
+ same channel. Discrete native input (`Mob.Event.NativeInput`).
+
+2. **They observe without consuming, on every node type**, `button` included
+ (unlike `on_long_press`, which follows iOS's `mobGestures` node set).
+ Android: a `pointerInput` on the Initial pass that never consumes, ending
+ when every pointer is up (Compose reports a cancel that way) or in a
+ `finally` when the detector is torn down. iOS 18+: a UIKit recognizer
+ (`UIGestureRecognizerRepresentable`) that never recognizes, recognizes
+ simultaneously with everything and neither cancels nor delays touches, so
+ the press ends in `touchesEnded` / `touchesCancelled`. A simultaneous
+ `DragGesture(minimumDistance: 0)` was tried first and rejected: a swipe
+ starting on the node no longer scrolled its ScrollView, and a quick tap
+ delivered `tap` before `press_in`. iOS 17 falls back to that DragGesture
+ behind a `@GestureState` (resets on cancel), accepting both flaws.
+
+3. **Always paired, so routing is snapshotted at touch-down.** A press almost
+ always causes a re-render ("listening"), and `set_root` commits the new
+ handle generation on the BEAM thread before the UI thread has the new
+ tree. A press_out looked up from its handle at lift time can therefore be
+ rejected as stale, and the app is left believing the finger is still
+ down. So at touch-down native resolves both handles identity-tolerantly
+ (same slot, pid and tag, like change events), copies press_out's pid and
+ tag into a slot of its own, and only then sends press_in; at the lift it
+ sends press_out from that copy, once. If a declared handle can't be
+ resolved, neither is sent. This is a deliberate exception to "plain taps
+ and the other gestures stay generation-strict" (AGENTS.md pre-empt rule
+ 3): the pair is one gesture whose second half must survive renders.
+
+ **A press node's tap is identity-tolerant too.** On the emulator, 1–6 of
+ 20 `tap_xy` taps on a box whose `on_press_in` changed its label were lost
+ (logcat: "rejected stale event handle"): the tap's handle belonged to the
+ tree before the press's render. For a node that declares `on_press_in` or
+ `on_press_out`, the tap goes through `mob_send_press_tap`, which resolves
+ like a change event: the slot still holds the same pid and tag, so the
+ `{:tap, tag}` delivered is the message the tapped node declared. The
+ bridge picks it: Android's `sendTapFor` in `RenderNodeInner` and
+ `MobButton`, iOS's `on_tap` deserialiser.
+ Plain taps keep the strict `mob_send_tap`: a first cut made every tap
+ tolerant, and review found that a stale positional tag such as Mob.List's
+ `{:select, id, index}` would then select whichever row the list had moved
+ into that index, where the strict lookup drops it. The other gestures stay
+ strict.
+
+4. **A held press in `Mob.Test` (Android): `press_down_xy/4`,
+ `press_move_xy/3`, `press_up_xy/3`, `hold_xy/4`.** The app's own
+ `MobBridge` dispatches in-process `MotionEvent`s at the decor view, like the
+ other synthetic gestures, but the finger stays down between calls. The
+ bridge returns an int code, not a boolean, so `:already_pressed`,
+ `:not_pressed` and `:window_gone` are distinguishable from
+ `:dispatch_failed`. One held press at a time; the other synthetic
+ gestures refuse while it is down, since their own DOWN would restart the
+ gesture stream under it. `:max_hold_ms` (30 s default) cancels a press a
+ crashed test never lifted.
+
+5. **iOS gets by-tag driving only.** No in-process touch injection reaches
+ SwiftUI (decisions/2026-08-09-tap-xy-reports-observed-effect.md), so the
+ `_xy` NIFs return `{:error, :not_supported}`; `press_in/2`, `press_out/2`
+ and `hold/3` send the messages on every platform, and a real touch comes
+ from the simulator (mobile-mcp / agent-device).
+
+## Consequences
+
+- The Android half lives in the generated `MobBridge.kt` / `beam_jni.c`, so it
+ needs `mob_new` 0.6.5 or newer; an older app ignores the props and its
+ `capabilities/1` reports the press NIFs false.
+- `decisions/2026-09-30-native-input-is-an-action.md` lists the discrete
+ events; `:press_in` / `:press_out` join them.
+- No `mob_dev` MCP surface exists to extend; agents reach these through
+ `Mob.Test` over dist.
+- Speech-to-text, the other half of hold-to-talk, is the `mob_speech` plugin,
+ not core: privacy-gated capabilities live in plugins
+ (plugin_extraction_plan.md, Waves 2 and 6).
diff --git a/guides/components.md b/guides/components.md
index ae5eaa3..59b9439 100644
--- a/guides/components.md
+++ b/guides/components.md
@@ -1030,6 +1030,51 @@ end
| `on_submit: {pid, tag}` | `{:submit, tag}` |
| `on_focus: {pid, tag}` | `{:focus, tag}` |
| `on_blur: {pid, tag}` | `{:blur, tag}` |
+| `on_long_press: {pid, tag}` | `{:long_press, tag}` |
+| `on_press_in: {pid, tag}` | `{:press_in, tag}` |
+| `on_press_out: {pid, tag}` | `{:press_out, tag}` |
+
+### Press and release: `on_press_in` / `on_press_out`
+
+`on_press_in` fires when a finger goes down on the node; `on_press_out` when
+that finger lifts **or** its touch is cancelled (the system took the gesture,
+the node left the screen). Sliding off the node, or an ancestor scrolling
+under the finger, does not end the press: it lasts until the finger lifts.
+On a node that declares both, every `{:press_in, tag}` is followed by exactly
+one `{:press_out, tag}`, even when the screen re-renders in between, which a
+press usually causes. They work on every node type, `:button` included, and
+they observe without consuming: `on_tap`, `on_long_press` and an ancestor's
+scrolling keep working on the same node. A quick tap delivers
+`{:press_in, tag}`, `{:press_out, tag}`, then `{:tap, tag}`.
+
+That is what hold-to-talk, push-to-record, "peek" previews and press-and-hold
+buttons need:
+
+```elixir
+def render(assigns) do
+ ~MOB"""
+
+
+
+ """
+end
+
+def handle_info({:press_in, :mic}, socket),
+ do: {:noreply, Mob.Socket.assign(socket, listening: true, pressed_at: System.monotonic_time(:millisecond))}
+
+def handle_info({:press_out, :mic}, socket) do
+ held = System.monotonic_time(:millisecond) - socket.assigns.pressed_at
+ # A press too short to say anything: cancel instead of transcribing.
+ {:noreply, Mob.Socket.assign(socket, listening: false, too_short: held < 300)}
+end
+```
+
+Measure the hold on the BEAM as above: both events cross the same channel,
+so the latency cancels out. Test it without a finger: `Mob.Test.hold/3`
+sends the pair by tag on any platform, and on Android `Mob.Test.hold_xy/4`
+(or `press_down_xy/4` … `press_up_xy/3`) holds a real touch through the
+native detector. Android needs a bridge generated by `mob_new` 0.6.5 or newer.
### Handle limits
diff --git a/guides/event_audit.md b/guides/event_audit.md
index 729b88b..261a161 100644
--- a/guides/event_audit.md
+++ b/guides/event_audit.md
@@ -27,6 +27,8 @@ registrations. Each is opt-in per widget; absence means no event delivery.
| `on_select` | `{pid, tag}` | `{:select, tag}` | Reserved for a future native selection primitive; no core picker/menu currently emits it. `Mob.List` routes indexed selection through row taps instead. |
| `on_long_press` | `{pid, tag}` | `{:long_press, tag}` | Renderer + iOS NIF + native gesture; Android wired |
| `on_double_tap` | `{pid, tag}` | `{:double_tap, tag}` | Same |
+| `on_press_in` | `{pid, tag}` | `{:press_in, tag}` | Finger down on the node. Observes without consuming; any node type. iOS + Android (bridge from `mob_new` 0.6.5+) (MOB-380) |
+| `on_press_out` | `{pid, tag}` | `{:press_out, tag}` | That finger lifted or its touch was cancelled; on a node declaring both, always follows its `press_in`. Routing is snapshotted at touch-down so a re-render can't drop it (MOB-380) |
| `on_swipe` | `{pid, tag}` | `{:swipe, tag, direction}` | Direction is `:left \| :right \| :up \| :down` |
| `on_swipe_left` | `{pid, tag}` | `{:swipe_left, tag}` | Specific direction only |
| `on_swipe_right` | `{pid, tag}` | `{:swipe_right, tag}` | |
diff --git a/guides/events.md b/guides/events.md
index 3062cb3..fa12154 100644
--- a/guides/events.md
+++ b/guides/events.md
@@ -70,6 +70,19 @@ def handle_info({:long_press, :show_menu}, socket), do: ...
def handle_info({:swipe, :any_swipe, direction}, socket), do: ... # :left | :right | :up | :down
```
+Press and release, for anything held (hold-to-talk, push-to-record):
+
+```elixir
+box(on_press_in: {self(), :mic}, on_press_out: {self(), :mic})
+
+def handle_info({:press_in, :mic}, socket), do: ... # finger down
+def handle_info({:press_out, :mic}, socket), do: ... # lifted or cancelled; always follows press_in
+```
+
+They observe without consuming, on any node type, and a quick tap delivers
+`press_in`, `press_out`, then `tap`. See the components guide,
+"Press and release".
+
### Scroll — three tiers
**Tier 1 — raw deltas** (rarely needed; throttle defaults to 30 Hz):
diff --git a/guides/mobile_surface_matrix.md b/guides/mobile_surface_matrix.md
index 9a2908b..3cf1cf0 100644
--- a/guides/mobile_surface_matrix.md
+++ b/guides/mobile_surface_matrix.md
@@ -75,6 +75,7 @@ and orthogonal — composition over a fat component library.
| Capability | Status | iOS | Android | Notes |
|--|--|--|--|--|
| Tap / double-tap / long-press | ✅ | ✓ | ✓ | `on_tap`, `on_double_tap`, `on_long_press` props |
+| Press in / out (hold) | ✅ | ✓ | ✓ | `on_press_in`, `on_press_out` props (MOB-380); `Mob.Test.hold_xy/4` drives a real held touch on Android |
| Swipe (l/r/u/d) | ✅ | ✓ | ✓ | `on_swipe_left`, etc. |
| Pan / drag gesture | 🟡 | 🟡 | 🟡 | Tap-based; full pan-responder system (like react-native-gesture-handler) is missing |
| Pinch / zoom | ❌ | — | — | Plugin candidate; common for image/map views |
diff --git a/guides/testing.md b/guides/testing.md
index 899ba7e..ef1bf1a 100644
--- a/guides/testing.md
+++ b/guides/testing.md
@@ -150,6 +150,35 @@ Mob.Test.tap(node, :increment)
The tag atom comes from `on_tap: {self(), :increment}` in the screen's `render/1`. Fire-and-forget — does not block. Follow with `settle/2` (below) before reading the native side.
+### Press and hold
+
+Two levels, for `on_press_in` / `on_press_out` (hold-to-talk and the like):
+
+```elixir
+# By tag, any platform: sends {:press_in, :mic}, waits, sends {:press_out, :mic}.
+Mob.Test.hold(node, :mic, 1_500)
+Mob.Test.press_in(node, :mic) # or the halves, with checks in between
+Mob.Test.press_out(node, :mic)
+
+# A real held touch (Android): the app's window gets ACTION_DOWN, then nothing
+# until you lift. Compose detectors (detectTapGestures' onPress /
+# tryAwaitRelease, long-press timeouts) see a finger that is genuinely down.
+:ok = Mob.Test.press_down_xy(node, 40.0, 700.0)
+Mob.Test.assigns(node).listening #=> true, while the finger is still down
+:ok = Mob.Test.press_move_xy(node, 40.0, 600.0) # optional: slide it
+:ok = Mob.Test.press_up_xy(node, 40.0, 600.0)
+
+:ok = Mob.Test.hold_xy(node, 40.0, 700.0, 1_500) # down, 1.5 s, up
+```
+
+Find coordinates with `Mob.Test.element_frames/1` (nodes with an `:id`). A
+held press auto-cancels after `max_hold_ms:` (default 30 s), so a crashed test
+can't leave a finger on the glass. `adb shell input swipe x y x y 3000` is
+not a substitute: on some devices it never reaches Compose as a held press.
+iOS has no in-process touch injection that reaches SwiftUI, so the `_xy`
+functions return `{:error, :not_supported}` there; use the by-tag functions, or
+a real touch through the simulator (mobile-mcp / agent-device).
+
### Navigation
Navigation functions are **synchronous** — they block until the navigation and re-render are complete, so it is safe to read state immediately after:
diff --git a/ios/MobInputEvents.swift b/ios/MobInputEvents.swift
index fafaf36..187e11f 100644
--- a/ios/MobInputEvents.swift
+++ b/ios/MobInputEvents.swift
@@ -125,6 +125,155 @@ struct MobContinuousInputModifier: ViewModifier {
}
}
+// Press observation (MOB-380): on_press_in at touch-down, on_press_out when
+// that finger lifts or the touch is cancelled. Observes without consuming:
+// on_tap, on_long_press, a Button's own action and an enclosing ScrollView's
+// pan keep working on the same node. Only attached to nodes with a press prop.
+//
+// The pairing itself lives native-side (MobNode.onPress / mob_begin_press):
+// `begin` sends press_in and hands back the one-shot release that sends the
+// matching press_out. This side only has to call that release exactly once
+// per touch, whichever way the touch ends, and the view leaving the tree
+// mid-press (onDisappear) counts as an end.
+//
+// iOS 18+: a UIKit recognizer that never recognizes (MobPressRecognizer).
+// A SwiftUI `DragGesture(minimumDistance: 0)`, even as a simultaneousGesture,
+// stops an enclosing ScrollView from scrolling when the drag starts on the
+// node (seen on the iOS 26 simulator), and inside a ScrollView its callbacks
+// for a quick tap arrive a frame after the tap's own action. The recognizer
+// sees the real touch-down, can't win against anything, and is ended by
+// UIKit's own reset on lift, cancel or failure.
+// iOS 17 has no UIGestureRecognizerRepresentable, so it keeps the DragGesture
+// (a GestureState reset covers cancellation). Unverified there: no iOS 17
+// runtime was available, so whether it blocks scrolling on 17 is unknown.
+struct MobPressModifier: ViewModifier {
+ let begin: () -> (() -> Void)?
+
+ @GestureState private var pressing = false
+ // Plain class in @State: survives node rebuilds without publishing a view
+ // invalidation per touch (same reasoning as MobContinuousInputModifier).
+ @State private var press = MobPressSession()
+
+ func body(content: Content) -> some View {
+ if #available(iOS 18.0, *) {
+ content
+ .contentShape(Rectangle())
+ .gesture(MobPressObserverGesture(session: press, begin: begin))
+ .onDisappear { press.end() }
+ } else {
+ content
+ .contentShape(Rectangle())
+ .simultaneousGesture(
+ DragGesture(minimumDistance: 0)
+ .updating($pressing) { _, state, _ in state = true }
+ .onChanged { _ in press.start(begin) }
+ .onEnded { _ in press.end() }
+ )
+ .onChange(of: pressing) { _, isPressing in
+ if !isPressing { press.end() }
+ }
+ .onDisappear { press.end() }
+ }
+ }
+}
+
+/// One touch's press, at most one at a time. Main thread only.
+final class MobPressSession {
+ private var active = false
+ private var release: (() -> Void)?
+
+ func start(_ begin: () -> (() -> Void)?) {
+ guard !active else { return }
+ active = true
+ release = begin()
+ }
+
+ func end() {
+ guard active else { return }
+ active = false
+ let pending = release
+ release = nil
+ pending?()
+ }
+}
+
+@available(iOS 18.0, *)
+struct MobPressObserverGesture: UIGestureRecognizerRepresentable {
+ let session: MobPressSession
+ let begin: () -> (() -> Void)?
+
+ func makeUIGestureRecognizer(context: Context) -> MobPressRecognizer {
+ let recognizer = MobPressRecognizer()
+ recognizer.delegate = context.coordinator
+ return recognizer
+ }
+
+ func updateUIGestureRecognizer(_ recognizer: MobPressRecognizer, context: Context) {
+ // The latest render's node: its handles are the current generation.
+ recognizer.session = session
+ recognizer.begin = begin
+ }
+
+ func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+ Coordinator()
+ }
+
+ final class Coordinator: NSObject, UIGestureRecognizerDelegate {
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
+ ) -> Bool {
+ true
+ }
+ }
+}
+
+/// Observes the first finger on its view and never recognizes, so it never
+/// cancels, delays or excludes another recognizer or the view's own touches.
+final class MobPressRecognizer: UIGestureRecognizer {
+ var session: MobPressSession?
+ var begin: (() -> (() -> Void)?)?
+ private weak var finger: UITouch?
+
+ init() {
+ super.init(target: nil, action: nil)
+ cancelsTouchesInView = false
+ delaysTouchesBegan = false
+ delaysTouchesEnded = false
+ }
+
+ override func touchesBegan(_ touches: Set, with event: UIEvent) {
+ guard finger == nil, let touch = touches.first, let begin else { return }
+ finger = touch
+ session?.start(begin)
+ }
+
+ // Ending here, not only in reset(), puts press_out ahead of a tap that the
+ // same lift completes: reset() runs after the whole touch event has been
+ // delivered, by which time the tap's action has already been sent.
+ override func touchesEnded(_ touches: Set, with event: UIEvent) {
+ finish(touches)
+ }
+
+ override func touchesCancelled(_ touches: Set, with event: UIEvent) {
+ finish(touches)
+ }
+
+ private func finish(_ touches: Set) {
+ guard let finger, touches.contains(finger) else { return }
+ session?.end()
+ state = .failed
+ }
+
+ // UIKit calls this after .failed, and also when it abandons the recognizer
+ // for any other reason, so a press that didn't end above still ends here.
+ override func reset() {
+ super.reset()
+ finger = nil
+ session?.end()
+ }
+}
+
extension MobNode {
/// `resolvedFont` for the UIKit field (`caret: "end"`, `on_compose`,
/// single-line `max_length`), from the same props. A custom font is
diff --git a/ios/MobNode.h b/ios/MobNode.h
index 40d3972..a06af4e 100644
--- a/ios/MobNode.h
+++ b/ios/MobNode.h
@@ -87,6 +87,15 @@ NS_ASSUME_NONNULL_BEGIN
@property(nonatomic, copy, nullable) void (^onSwipeUp)(void);
@property(nonatomic, copy, nullable) void (^onSwipeDown)(void);
+// Press observation (MOB-380): on_press_in / on_press_out. Set by mob_nif.m when
+// either prop is registered; SwiftUI calls it at touch-down. It sends press_in
+// and returns the one-shot block that sends the paired press_out, to be called
+// once at lift, cancel or teardown. Returns nil when there is nothing to send at
+// release (press_in could not be delivered, or no on_press_out), so a press_out
+// never goes out without its press_in. The pairing lives native-side; see
+// mob_begin_press in mob_nif.m for why it can't be two independent callbacks.
+@property(nonatomic, copy, nullable) void (^_Nullable (^onPress)(void))(void);
+
// ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ──
// These callbacks are wired by mob_nif.m. Throttling and delta-thresholding
// happen native-side BEFORE invocation — by the time these fire, the BEAM
diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift
index d5d3184..61e350d 100644
--- a/ios/MobRootView.swift
+++ b/ios/MobRootView.swift
@@ -644,10 +644,15 @@ struct MobNodeView: View {
// carrying an :id, so the agent can read positions via the
// element_frames NIF without a screenshot.
.modifier(MobFrameTracker(node: node))
- // Pinch, rotation, and pointer hover belong to the rendered node as a
- // whole. Keeping this outside the type switch covers every primitive,
- // including GPU/native views that do not use mobGestures().
+ // Pinch, rotation, pointer hover and press in/out (MOB-380) belong to
+ // the rendered node as a whole. Keeping them outside the type switch
+ // covers every primitive — buttons, fields, sliders and toggles, which
+ // never get mobGestures(), and GPU/native views — with one attachment
+ // point instead of one per case.
.modifier(MobContinuousInputModifier(node: node))
+ .ifLet(node.onPress) { view, begin in
+ view.modifier(MobPressModifier(begin: begin))
+ }
// In a Row, flexible Spacer views yield only after direct text labels
// reach their ideal width. Without this priority, SwiftUI compresses
// labels before consuming the Spacer's slack, producing avoidable wraps.
diff --git a/ios/mob_nif.m b/ios/mob_nif.m
index 8d78bc7..78f0f46 100644
--- a/ios/mob_nif.m
+++ b/ios/mob_nif.m
@@ -433,12 +433,16 @@ static void mob_note_ui_event(void) {
}
// Called from node onTap blocks — routes tap to BEAM via enif_send.
-static void mob_send_tap(int handle) {
+// identity_tolerant picks the lookup: NO (mob_send_tap) is generation-strict,
+// YES (mob_send_press_tap) accepts the same slot, PID and tag across renders.
+static void mob_send_tap_with(int handle, BOOL identity_tolerant) {
ErlNifEnv *msg_env = enif_alloc_env();
if (!msg_env)
return;
TapSnap snap;
- if (!mob_snap_tap(handle, msg_env, &snap)) {
+ int ok = identity_tolerant ? mob_snap_change_tap(handle, msg_env, &snap)
+ : mob_snap_tap(handle, msg_env, &snap);
+ if (!ok) {
enif_free_env(msg_env);
return;
}
@@ -449,6 +453,24 @@ static void mob_send_tap(int handle) {
enif_free_env(msg_env);
}
+// Generation-strict: a handle from an earlier render is dropped, so a
+// positional tag (Mob.List's {:select, id, index}) never reaches a row that
+// moved under it.
+static void mob_send_tap(int handle) {
+ mob_send_tap_with(handle, NO);
+}
+
+// The tap of a node that also declares on_press_in / on_press_out (MOB-380).
+// Identity-tolerant (mob_snap_change_tap: same slot, PID and tag across
+// renders): press_in usually re-renders the screen, and set_root commits the
+// new table on the BEAM thread before the main thread applies the new tree, so
+// the lift that completes the tap fires the old node's handle; a strict check
+// dropped those taps (measured on Android, 1-6 of 20). The identity check still
+// rejects a handle whose slot now means another handler. Plain taps stay strict.
+static void mob_send_press_tap(int handle) {
+ mob_send_tap_with(handle, YES);
+}
+
// ── Focus / blur / submit senders ────────────────────────────────────────────
// Called from MobTextField SwiftUI view when focus state changes or return key tapped.
@@ -577,6 +599,103 @@ static void mob_send_swipe_with_direction(int handle, const char *direction) {
enif_free_env(msg_env);
}
+// ── Press observation (MOB-380) ─────────────────────────────────────────────
+// press_in / press_out bracket one touch, and a hold-to-talk screen stops
+// recording on press_out: a lost press_out leaves the microphone on. So unlike
+// the gesture senders above, the two are resolved together, at touch-down, and
+// are deliberately not generation-strict:
+//
+// * Both handles resolve with mob_snap_change_tap (same slot, PID and tag
+// across renders), the allowance change events get. press_in usually makes
+// the screen re-render, and set_root commits the new table on the BEAM
+// thread before the main thread has the new tree; a strict check drops an
+// event fired in that window, which for press_in is a press that never
+// happened and for press_out is a press that never ends.
+// * press_out's PID and tag are snapshotted BEFORE press_in is sent, and if
+// either fails to resolve nothing is sent. The release block then sends from
+// the snapshot, so no later render, slot reshuffle or the node leaving the
+// tree can drop it: press_out goes to exactly the registration that was live
+// when press_in went out. That is not the stale-tree hazard the strict rule
+// guards against, which is an old handle resolving to a slot that now means
+// something else.
+
+@interface MobPendingPressOut : NSObject {
+ @public
+ ErlNifEnv *env; // owns `tag`; NULL once sent
+ ErlNifPid pid;
+ ERL_NIF_TERM tag;
+}
+@end
+
+@implementation MobPendingPressOut
+- (void)dealloc {
+ // The release block was dropped without being called: free, don't send.
+ // The Swift side calls it on lift, cancel and disappear, so this is only
+ // reached if SwiftUI discards the view's state without any of those.
+ if (env)
+ enif_free_env(env);
+}
+@end
+
+// Main thread only (the release block's caller), which is what makes the
+// one-shot check-and-clear of `env` safe without a lock.
+static void mob_send_pending_press_out(MobPendingPressOut *pending) {
+ ErlNifEnv *env = pending->env;
+ if (!env)
+ return;
+ pending->env = NULL;
+ mob_note_ui_event();
+ ERL_NIF_TERM msg = enif_make_tuple2(env, enif_make_atom(env, "press_out"), pending->tag);
+ enif_send(NULL, &pending->pid, env, msg);
+ enif_free_env(env);
+}
+
+// Returns 1 if {press_in, tag} was sent.
+static int mob_send_press_in(int handle) {
+ ErlNifEnv *msg_env = enif_alloc_env();
+ if (!msg_env)
+ return 0;
+ TapSnap snap;
+ if (!mob_snap_change_tap(handle, msg_env, &snap)) {
+ enif_free_env(msg_env);
+ return 0;
+ }
+ mob_note_ui_event();
+ ERL_NIF_TERM msg = enif_make_tuple2(msg_env, enif_make_atom(msg_env, "press_in"), snap.tag);
+ enif_send(NULL, &snap.pid, msg_env, msg);
+ enif_free_env(msg_env);
+ return 1;
+}
+
+// in_handle / out_handle are 0 when the node has no such prop (a real handle
+// is never 0: its generation bits are non-zero). Returns the release block, or
+// nil when there is nothing to send at release.
+static void (^mob_begin_press(int in_handle, int out_handle))(void) {
+ MobPendingPressOut *pending = nil;
+ if (out_handle != 0) {
+ ErlNifEnv *env = enif_alloc_env();
+ if (!env)
+ return nil;
+ TapSnap snap;
+ if (!mob_snap_change_tap(out_handle, env, &snap)) {
+ enif_free_env(env);
+ return nil;
+ }
+ pending = [MobPendingPressOut new];
+ pending->env = env;
+ pending->pid = snap.pid;
+ pending->tag = snap.tag;
+ }
+ // `pending` is released (and its env freed) on this early return.
+ if (in_handle != 0 && !mob_send_press_in(in_handle))
+ return nil;
+ if (!pending)
+ return nil;
+ return ^{
+ mob_send_pending_press_out(pending);
+ };
+}
+
// ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate senders ─────────
// Each respects the per-handle throttle config set via mob_set_throttle_config.
// The envelope follows the canonical Mob.Event shape but is constructed at
@@ -974,6 +1093,8 @@ typedef NS_ENUM(NSUInteger, MobPropKey) {
MOB_PROP_on_long_press,
MOB_PROP_on_pinch,
MOB_PROP_on_pointer_move,
+ MOB_PROP_on_press_in,
+ MOB_PROP_on_press_out,
MOB_PROP_on_rotate,
MOB_PROP_on_scroll,
MOB_PROP_on_scroll_began,
@@ -1109,6 +1230,8 @@ typedef NS_ENUM(NSUInteger, MobPropKey) {
[MOB_PROP_on_long_press] = @"on_long_press",
[MOB_PROP_on_pinch] = @"on_pinch",
[MOB_PROP_on_pointer_move] = @"on_pointer_move",
+ [MOB_PROP_on_press_in] = @"on_press_in",
+ [MOB_PROP_on_press_out] = @"on_press_out",
[MOB_PROP_on_rotate] = @"on_rotate",
[MOB_PROP_on_scroll] = @"on_scroll",
[MOB_PROP_on_scroll_began] = @"on_scroll_began",
@@ -1425,9 +1548,19 @@ typedef NS_ENUM(NSUInteger, MobPropKey) {
id onTap = pv[MOB_PROP_on_tap];
if (onTap && [onTap isKindOfClass:[NSNumber class]]) {
int handle = [onTap intValue];
- node.onTap = ^{
- mob_send_tap(handle);
- };
+ // A node that observes press in/out re-renders mid-touch, so its
+ // tap resolves identity-tolerantly; every other tap stays strict.
+ BOOL pressNode = [pv[MOB_PROP_on_press_in] isKindOfClass:[NSNumber class]] ||
+ [pv[MOB_PROP_on_press_out] isKindOfClass:[NSNumber class]];
+ if (pressNode) {
+ node.onTap = ^{
+ mob_send_press_tap(handle);
+ };
+ } else {
+ node.onTap = ^{
+ mob_send_tap(handle);
+ };
+ }
}
id placeholder = pv[MOB_PROP_placeholder];
@@ -1511,6 +1644,18 @@ typedef NS_ENUM(NSUInteger, MobPropKey) {
};
}
+ id onPressIn = pv[MOB_PROP_on_press_in];
+ id onPressOut = pv[MOB_PROP_on_press_out];
+ BOOL hasPressIn = onPressIn && [onPressIn isKindOfClass:[NSNumber class]];
+ BOOL hasPressOut = onPressOut && [onPressOut isKindOfClass:[NSNumber class]];
+ if (hasPressIn || hasPressOut) {
+ int inHandle = hasPressIn ? [onPressIn intValue] : 0;
+ int outHandle = hasPressOut ? [onPressOut intValue] : 0;
+ node.onPress = ^void(^_Nullable(void))(void) {
+ return mob_begin_press(inHandle, outHandle);
+ };
+ }
+
id onDoubleTap = pv[MOB_PROP_on_double_tap];
if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) {
int handle = [onDoubleTap intValue];
@@ -6883,6 +7028,54 @@ static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TE
#endif
}
+// ─── press_down_xy/3, press_move_xy/2, press_up_xy/2 — split touch (MOB-380) ──
+//
+// A finger that goes down, optionally moves, and lifts in separate calls, so a
+// test can hold a node (on_press_in / on_press_out) for as long as it likes and
+// assert in between. Android injects real in-process MotionEvents. iOS does
+// not implement split touch injection (yet): the simulator branches above fire
+// gestures by forcing UIKit recognizer states or accessibility actions, not by
+// delivering a held touch stream. So these return {error, not_supported}
+// rather than pretending to have held anything.
+// Mob.Test.press_in/2, press_out/2 and hold/3 cover iOS at the tag level, and
+// capabilities/0 reports these three as false so an agent can tell up front.
+//
+// Arguments are still validated, so a malformed call is badarg on both
+// platforms rather than only on the one that implements it.
+//
+// Returns: {error, not_supported} | badarg
+
+static ERL_NIF_TERM mob_press_not_supported(ErlNifEnv *env) {
+ return enif_make_tuple2(env, enif_make_atom(env, "error"),
+ enif_make_atom(env, "not_supported"));
+}
+
+static ERL_NIF_TERM nif_press_down_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
+ (void)argc;
+ double x, y;
+ int max_hold_ms;
+ if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y) ||
+ !enif_get_int(env, argv[2], &max_hold_ms))
+ return enif_make_badarg(env);
+ return mob_press_not_supported(env);
+}
+
+static ERL_NIF_TERM nif_press_move_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
+ (void)argc;
+ double x, y;
+ if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y))
+ return enif_make_badarg(env);
+ return mob_press_not_supported(env);
+}
+
+static ERL_NIF_TERM nif_press_up_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
+ (void)argc;
+ double x, y;
+ if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y))
+ return enif_make_badarg(env);
+ return mob_press_not_supported(env);
+}
+
// ─── type_text/1 — type into whatever UITextField/UITextView has focus ────────
//
// Finds the current first responder in the view hierarchy and calls insertText:
@@ -7592,6 +7785,11 @@ static ERL_NIF_TERM nif_capabilities(ErlNifEnv *env, int argc, const ERL_NIF_TER
{"tap_xy", harness},
{"tap_by_label", harness},
{"long_press_xy", harness},
+ // Split press injection is Android-only (MOB-380): on iOS these exist
+ // in debug builds but always return {error, not_supported}.
+ {"press_down_xy", 0},
+ {"press_move_xy", 0},
+ {"press_up_xy", 0},
{"swipe_xy", harness},
{"type_text", harness},
{"delete_backward", harness},
@@ -8811,6 +9009,10 @@ static ERL_NIF_TERM nif_post_mortem_android_drain(ErlNifEnv *env, int argc,
{"key_press", 1, nif_key_press, ERL_NIF_DIRTY_JOB_IO_BOUND},
{"clear_text", 0, nif_clear_text, ERL_NIF_DIRTY_JOB_IO_BOUND},
{"long_press_xy", 3, nif_long_press_xy, ERL_NIF_DIRTY_JOB_IO_BOUND},
+ // Validate and return {error, not_supported}; nothing waits, so not dirty.
+ {"press_down_xy", 3, nif_press_down_xy, 0},
+ {"press_move_xy", 2, nif_press_move_xy, 0},
+ {"press_up_xy", 2, nif_press_up_xy, 0},
{"swipe_xy", 4, nif_swipe_xy, ERL_NIF_DIRTY_JOB_IO_BOUND},
#endif
{"capabilities", 0, nif_capabilities, 0},
diff --git a/lib/mob/event/native_input.ex b/lib/mob/event/native_input.ex
index 73ead2a..b19e7b8 100644
--- a/lib/mob/event/native_input.ex
+++ b/lib/mob/event/native_input.ex
@@ -15,7 +15,7 @@ defmodule Mob.Event.NativeInput do
| Kind | Messages | Receipt | Trace |
|---|---|---|---|
- | `:discrete` | `:tap` (list-row select included), `:focus`, `:blur`, `:submit`, `:select`, `:dismiss`, `:long_press`, `:double_tap`, `:swipe_left`/`_right`/`_up`/`_down`, `{:swipe, tag, direction}`, `{:change, tag, value}` except a float | yes | yes |
+ | `:discrete` | `:tap` (list-row select included), `:focus`, `:blur`, `:submit`, `:select`, `:dismiss`, `:long_press`, `:double_tap`, `:press_in`, `:press_out`, `:swipe_left`/`_right`/`_up`/`_down`, `{:swipe, tag, direction}`, `{:change, tag, value}` except a float | yes | yes |
| `:stream` | `:scroll`, `:drag`, `:pinch`, `:rotate`, `:pointer_move`, `:compose`, a float `:change`, and the scroll lifecycle (`:scroll_began`, `:scroll_ended`, `:scroll_settled`, `:top_reached`, `:scrolled_past`) | no | yes |
A stream fires at up to display rate, or several times per gesture. The
@@ -56,6 +56,8 @@ defmodule Mob.Event.NativeInput do
:dismiss,
:long_press,
:double_tap,
+ :press_in,
+ :press_out,
:swipe_left,
:swipe_right,
:swipe_up,
diff --git a/lib/mob/render_stats.ex b/lib/mob/render_stats.ex
index 1c0e9b1..c6f65e3 100644
--- a/lib/mob/render_stats.ex
+++ b/lib/mob/render_stats.ex
@@ -489,6 +489,7 @@ defmodule Mob.RenderStats do
# of this reported 1 tap on a tree that made 12 calls.
@handle_props MapSet.new(~w(on_tap on_change on_focus on_blur on_submit on_dismiss on_select
on_scroll on_drag on_pinch on_rotate on_long_press on_double_tap
+ on_press_in on_press_out
on_swipe on_swipe_left on_swipe_right on_swipe_up on_swipe_down
on_compose on_end_reached on_tab_select on_pointer_move
on_scroll_began on_scroll_ended on_scroll_settled on_top_reached
diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex
index 5d0aebd..b62fb07 100644
--- a/lib/mob/renderer.ex
+++ b/lib/mob/renderer.ex
@@ -465,6 +465,17 @@ defmodule Mob.Renderer do
{:on_double_tap, {pid, tag}} when is_pid(pid) ->
[{"on_double_tap", register_handler(nif, {pid, tag})}]
+ # Press in / out (MOB-380): finger down on the node / that finger lifted
+ # or its gesture was cancelled. Observes without consuming, so on_tap,
+ # long press and scrolling on the same node keep working. Native
+ # snapshots press_out's routing at touch-down, so the pair survives the
+ # re-render a press usually causes.
+ {:on_press_in, {pid, tag}} when is_pid(pid) ->
+ [{"on_press_in", register_handler(nif, {pid, tag})}]
+
+ {:on_press_out, {pid, tag}} when is_pid(pid) ->
+ [{"on_press_out", register_handler(nif, {pid, tag})}]
+
{:on_swipe, {pid, tag}} when is_pid(pid) ->
[{"on_swipe", register_handler(nif, {pid, tag})}]
diff --git a/lib/mob/test.ex b/lib/mob/test.ex
index 40c7fab..a89fc4b 100644
--- a/lib/mob/test.ex
+++ b/lib/mob/test.ex
@@ -120,6 +120,7 @@ defmodule Mob.Test do
|------------------------------|---------------|---------------|-----------------|
| `screen/1`, `assigns/1` | ✅ | ✅ | ✅ |
| `tap/2` (by tag) | ✅ | ✅ | ✅ |
+ | `press_in/2`, `press_out/2`, `hold/3` (by tag) | ✅ | ✅ | ✅ |
| `back/1`, `pop/1`, `navigate`| ✅ | ✅ | ✅ |
| `send_message/2` | ✅ | ✅ | ✅ |
| `screen_info/1` | ✅ | ✅ | ✅ |
@@ -134,6 +135,7 @@ defmodule Mob.Test do
| `adjust_slider/4` | ⚠️ AX active§ | ⚠️ AX active§ | ❌ ui_tree_unavailable |
| `tap_xy/3` | ⚠️ AX-activatable only¶ | ❌ no_effect¶ | ✅ ⊕ |
| `long_press_xy/4` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ⊕ |
+ | `press_down_xy/4`, `press_move_xy/3`, `press_up_xy/3`, `hold_xy/4` | ❌ not_supported | ❌ not_supported | ✅ ⊕ |
| `swipe/5` | ⚠️ scroll only| ⚠️ acceptance only✱| ✅ ⊕ |
| `type_text/2` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ASCII only⊕ |
| `delete_backward/1` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ⊕ |
@@ -187,6 +189,11 @@ defmodule Mob.Test do
an existing app needs regenerating. `capabilities/1` answers this for the
build in front of you; the table cannot.
+ The held press (`press_down_xy/4` and friends) and the `on_press_in` /
+ `on_press_out` props need a bridge from `mob_new` 0.6.5 or newer
+ (MOB-380): an older app answers `{:error, :not_loaded}` here and never
+ fires the props.
+
Four more consequences worth knowing before you rely on it:
* **Gestures cost real wall-clock.** A long press or swipe has to hold
@@ -543,7 +550,10 @@ defmodule Mob.Test do
:scroll_to,
:sample_region,
:screenshot,
- :native_stats
+ :native_stats,
+ :press_down_xy,
+ :press_move_xy,
+ :press_up_xy
]
@doc false
@@ -1204,7 +1214,10 @@ defmodule Mob.Test do
**not working on device** and use `tap/2`. See
`decisions/2026-08-09-ios-device-tap-injection-has-no-effect.md`.
- **Android** — not routed through this function; `adb shell input tap` works
- and is what the tooling uses.
+ and is what the tooling uses. While a `press_down_xy/4` is held, the
+ in-process synthetic gestures (`tap_xy`, `long_press_xy`, `swipe_xy`)
+ refuse and answer `{:error, :dispatch_failed}`: lift it with
+ `press_up_xy/3` first.
## `:no_effect` in sidecar mode
@@ -1293,6 +1306,138 @@ defmodule Mob.Test do
:rpc.call(node, :mob_nif, :swipe_xy, [x1 * 1.0, y1 * 1.0, x2 * 1.0, y2 * 1.0])
end
+ # ── Press and hold (MOB-380) ──────────────────────────────────────────────────
+
+ @doc """
+ Send `{:press_in, tag}` to the current screen, as a finger landing on a node
+ with `on_press_in: {pid, tag}` would. Works on every platform; no touch is
+ involved, so it tests the screen's handling, not the native detector. Pair
+ it with `press_out/2`, or use `hold/3`.
+
+ Mob.Test.press_in(node, :mic)
+ """
+ @spec press_in(node(), term()) :: :ok
+ def press_in(node, tag) do
+ :rpc.call(node, Process, :send, [:mob_screen, {:press_in, tag}, []])
+ :ok
+ end
+
+ @doc "Send `{:press_out, tag}` to the current screen. See `press_in/2`."
+ @spec press_out(node(), term()) :: :ok
+ def press_out(node, tag) do
+ :rpc.call(node, Process, :send, [:mob_screen, {:press_out, tag}, []])
+ :ok
+ end
+
+ @doc """
+ `press_in/2`, wait `duration_ms`, `press_out/2`: a hold, by tag, on every
+ platform. Blocks the caller for the duration.
+
+ Mob.Test.hold(node, :mic, 1_500)
+ """
+ @spec hold(node(), term(), non_neg_integer()) :: :ok
+ def hold(node, tag, duration_ms) when is_integer(duration_ms) and duration_ms >= 0 do
+ press_in(node, tag)
+ Process.sleep(duration_ms)
+ press_out(node, tag)
+ end
+
+ @default_max_hold_ms 30_000
+
+ @doc """
+ Put a finger down at (x, y) dp and leave it there until `press_up_xy/3`.
+
+ A genuine held touch, not a message: on Android the app's own window gets an
+ in-process `ACTION_DOWN`, so `on_press_in` fires through the native detector,
+ and a Compose `detectTapGestures` `onPress` is still waiting in
+ `tryAwaitRelease()` while you look at the app (`assigns/1`, `screenshot/2`,
+ logs). Between down and up you can read state, move the finger with
+ `press_move_xy/3`, or send messages; other synthetic gestures (`tap_xy/3`,
+ `swipe/5`, `long_press_xy/4`) refuse while the finger is down, since their
+ own touch would restart the gesture under it.
+
+ Options:
+
+ * `:max_hold_ms` (default #{@default_max_hold_ms}): the finger is
+ cancelled, not released, after this long, so a test that crashes
+ between down and up does not leave the app believing a finger is on the
+ glass for the rest of the session.
+
+ Returns `:ok`, or `{:error, reason}`: `:already_pressed` (one held press at
+ a time), `:dispatch_failed` (nothing in the window took the touch),
+ `:no_window`, `:timeout` (the UI thread didn't answer within 2 s, e.g.
+ another synthetic gesture still running), `:not_loaded` (an app whose generated `MobBridge.kt` predates
+ the held-press methods — see the `⊕` note above; `capabilities/1` tells you),
+ `:not_supported` (iOS: no in-process touch injection reaches SwiftUI; drive
+ the screen with `press_in/2` / `hold/3`, or a real touch through the
+ simulator).
+
+ :ok = Mob.Test.press_down_xy(node, 40.0, 700.0)
+ Mob.Test.assigns(node).listening #=> true
+ :ok = Mob.Test.press_up_xy(node, 40.0, 700.0)
+ """
+ @spec press_down_xy(node(), number(), number(), keyword()) :: :ok | {:error, term()}
+ def press_down_xy(node, x, y, opts \\ []) do
+ max_hold_ms = Keyword.get(opts, :max_hold_ms, @default_max_hold_ms)
+
+ node
+ |> :rpc.call(:mob_nif, :press_down_xy, [x * 1.0, y * 1.0, max_hold_ms])
+ |> harness_result()
+ end
+
+ @doc """
+ Move the finger held by `press_down_xy/4` to (x, y) dp, over a few real
+ frames so touch slop can be crossed. `{:error, :not_pressed}` when no finger
+ is down.
+ """
+ @spec press_move_xy(node(), number(), number()) :: :ok | {:error, term()}
+ def press_move_xy(node, x, y) do
+ node
+ |> :rpc.call(:mob_nif, :press_move_xy, [x * 1.0, y * 1.0])
+ |> harness_result()
+ end
+
+ @doc """
+ Lift the finger held by `press_down_xy/4` at (x, y) dp (moving it there
+ first if it is elsewhere). `{:error, :not_pressed}` when no finger is down
+ (including after `:max_hold_ms` cancelled it); `{:error, :window_gone}` when
+ the window the press started in has since been detached.
+ """
+ @spec press_up_xy(node(), number(), number()) :: :ok | {:error, term()}
+ def press_up_xy(node, x, y) do
+ node
+ |> :rpc.call(:mob_nif, :press_up_xy, [x * 1.0, y * 1.0])
+ |> harness_result()
+ end
+
+ @doc """
+ Hold a real finger at (x, y) dp for `duration_ms`, then lift it:
+ `press_down_xy/4`, sleep, `press_up_xy/3`. Unlike `long_press_xy/4` each
+ stage reports its own result, and the press stays a press — use this for
+ hold-to-talk and anything else keyed on press in / out.
+
+ :ok = Mob.Test.hold_xy(node, 40.0, 700.0, 1_500)
+ """
+ @spec hold_xy(node(), number(), number(), non_neg_integer()) :: :ok | {:error, term()}
+ def hold_xy(node, x, y, duration_ms) when is_integer(duration_ms) and duration_ms >= 0 do
+ with :ok <- press_down_xy(node, x, y, max_hold_ms: duration_ms + 10_000) do
+ Process.sleep(duration_ms)
+ press_up_xy(node, x, y)
+ end
+ end
+
+ @doc false
+ # An app running a mob without these NIFs answers `undef`; say what that
+ # means rather than handing the caller an RPC exit to decode.
+ @spec harness_result(term()) :: :ok | {:error, term()}
+ def harness_result(:ok), do: :ok
+ def harness_result({:error, _reason} = error), do: error
+ def harness_result({:badrpc, {:EXIT, {:undef, _}}}), do: {:error, :not_loaded}
+ def harness_result({:badrpc, {:EXIT, {:not_loaded, _}}}), do: {:error, :not_loaded}
+ def harness_result({:badrpc, reason}), do: {:error, {:badrpc, reason}}
+ # The Android NIFs answer a bare `error` when the thread can't attach to the JVM.
+ def harness_result(:error), do: {:error, :jni_attach_failed}
+
@doc """
Find elements in the native accessibility tree whose label or value contains `text`.
diff --git a/src/mob_nif.erl b/src/mob_nif.erl
index 03e1fcf..1f0945a 100644
--- a/src/mob_nif.erl
+++ b/src/mob_nif.erl
@@ -106,6 +106,9 @@
clear_text/0,
long_press_xy/3,
swipe_xy/4,
+ press_down_xy/3,
+ press_move_xy/2,
+ press_up_xy/2,
%% Test harness — in-process visual capture and scroll control
%% (remote-driving: agent gets pixels + deterministic scroll over dist,
%% no adb/xcrun). See Mob.Test.screenshot/2, scroll_info/2, scroll_to/3.
@@ -204,6 +207,9 @@
clear_text/0,
long_press_xy/3,
swipe_xy/4,
+ press_down_xy/3,
+ press_move_xy/2,
+ press_up_xy/2,
screenshot/3,
sample_region/4,
scroll_info/1,
@@ -365,6 +371,12 @@ 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).
+%% Held press (MOB-380): a finger that stays down across calls. Android
+%% dispatches in-process MotionEvents; iOS returns {error, not_supported}.
+%% press_down_xy(X, Y, MaxHoldMs) -> ok | {error, Reason}
+press_down_xy(_X, _Y, _MaxHoldMs) -> erlang:nif_error(not_loaded).
+press_move_xy(_X, _Y) -> erlang:nif_error(not_loaded).
+press_up_xy(_X, _Y) -> erlang:nif_error(not_loaded).
%% In-process visual capture + scroll control (see Mob.Test).
%% screenshot(Format, Quality, Scale) -> Binary (PNG/JPEG bytes) | {error, Reason}
%% Format :: png | jpeg, Quality :: 0..100 (jpeg), Scale :: float
diff --git a/test/mob/agent/native_input_receipts_test.exs b/test/mob/agent/native_input_receipts_test.exs
index 5f6db25..d7fa7ba 100644
--- a/test/mob/agent/native_input_receipts_test.exs
+++ b/test/mob/agent/native_input_receipts_test.exs
@@ -93,6 +93,14 @@ defmodule Mob.Agent.NativeInputReceiptsTest do
assert Receipt.owner(receipt) == :app_code
end
+ test "a press in and out are each receipted as discrete input on the node", %{screen: screen} do
+ deliver(screen, {:press_in, :mic})
+ assert {:press_in, %Address{screen: Screen, id: :mic}} = recent().event
+
+ deliver(screen, {:press_out, :mic})
+ assert {:press_out, %Address{screen: Screen, id: :mic}} = recent().event
+ end
+
test "a raising tap handler is recorded on the way out", %{screen: screen} do
ref = Process.monitor(screen)
diff --git a/test/mob/input_nif_scheduling_test.exs b/test/mob/input_nif_scheduling_test.exs
index 16ee0c2..189f970 100644
--- a/test/mob/input_nif_scheduling_test.exs
+++ b/test/mob/input_nif_scheduling_test.exs
@@ -29,7 +29,10 @@ defmodule Mob.InputNifSchedulingTest do
# three of the Android ones waited on a latch with no timeout at all.
@blocking_both ~w(screen_info scroll_to clipboard_get webview_can_go_back safe_area)
- @android_only_blocking ~w()
+ # MOB-380's held press: each call waits on the main looper (and on any
+ # gesture holding the gesture mutex). iOS's are stubs that return
+ # :not_supported, so they stay on a normal scheduler there.
+ @android_only_blocking ~w(press_down_xy press_move_xy press_up_xy)
# iOS blocks on these; Android answers them without touching the UI thread,
# so flagging them there would buy a scheduler hop and nothing.
diff --git a/test/mob/native_event_handle_test.exs b/test/mob/native_event_handle_test.exs
index 8a8ce96..a3a45a9 100644
--- a/test/mob/native_event_handle_test.exs
+++ b/test/mob/native_event_handle_test.exs
@@ -201,6 +201,55 @@ defmodule Mob.NativeEventHandleTest do
assert ios_dismiss =~ "mob_send_identity_event(handle, \"dismiss\")"
end
+ test "plain taps stay strict; press-node taps and press pairs survive a re-render" do
+ # A plain tap resolves generation-strictly: a stale positional tag such as
+ # Mob.List's {:select, id, index} must be dropped, not routed to whatever
+ # row now sits in that slot.
+ [_, android_tap] = String.split(@android_source, "pub export fn mob_send_tap(", parts: 2)
+ [android_tap, _] = String.split(android_tap, "}", parts: 2)
+ assert android_tap =~ "sendEvent(handle, \"tap\")"
+
+ [_, ios_tap] = String.split(@ios_source, "static void mob_send_tap(", parts: 2)
+ [ios_tap, _] = String.split(ios_tap, "}", parts: 2)
+ assert ios_tap =~ "mob_send_tap_with(handle, NO)"
+
+ # MOB-380: on_press_in almost always re-renders before the finger lifts, so
+ # the tap of a node that observes press in/out resolves by identity.
+ [_, android_press_tap] =
+ String.split(@android_source, "pub export fn mob_send_press_tap(", parts: 2)
+
+ [android_press_tap, _] = String.split(android_press_tap, "}", parts: 2)
+ assert android_press_tap =~ "sendIdentityEvent(handle, \"tap\")"
+
+ [_, ios_press_tap] = String.split(@ios_source, "static void mob_send_press_tap(", parts: 2)
+ [ios_press_tap, _] = String.split(ios_press_tap, "}", parts: 2)
+ assert ios_press_tap =~ "mob_send_tap_with(handle, YES)"
+
+ [_, ios_with] = String.split(@ios_source, "static void mob_send_tap_with(", parts: 2)
+ [ios_with, _] = String.split(ios_with, "static void mob_send_tap(", parts: 2)
+ assert ios_with =~ "identity_tolerant ? mob_snap_change_tap(handle, msg_env, &snap)"
+ assert ios_with =~ ": mob_snap_tap(handle, msg_env, &snap)"
+
+ # iOS picks the tolerant sender only for a node declaring press in/out.
+ [_, ios_on_tap] = String.split(@ios_source, "id onTap = pv[MOB_PROP_on_tap];", parts: 2)
+
+ [ios_on_tap, _] =
+ String.split(ios_on_tap, "id placeholder = pv[MOB_PROP_placeholder];", parts: 2)
+
+ assert ios_on_tap =~ "pv[MOB_PROP_on_press_in]"
+ assert ios_on_tap =~ "pv[MOB_PROP_on_press_out]"
+ assert ios_on_tap =~ "mob_send_press_tap(handle)"
+
+ # press_out's routing is copied at touch-down and sent from that copy.
+ [_, begin] = String.split(@android_source, "pub export fn mob_press_begin(", parts: 2)
+ [begin, _] = String.split(begin, "pub export fn mob_press_end(", parts: 2)
+ assert begin =~ "snapChangeTap(out_handle, keep)"
+ assert begin =~ "snapChangeTap(in_handle, env)"
+ {snap_out, _} = :binary.match(begin, "snapChangeTap(out_handle, keep)")
+ {send_in, _} = :binary.match(begin, "erts.enif_send(null, &pid, env, msg)")
+ assert snap_out < send_in
+ end
+
test "iOS copies every routed tag while holding the registry lock" do
[_, snap] = String.split(@ios_source, "static int mob_snap_tap", parts: 2)
[snap, _] = String.split(snap, "static int mob_snap_change_tap", parts: 2)
diff --git a/test/mob/nif_scheduling_completeness_test.exs b/test/mob/nif_scheduling_completeness_test.exs
index ef80362..4c23e0d 100644
--- a/test/mob/nif_scheduling_completeness_test.exs
+++ b/test/mob/nif_scheduling_completeness_test.exs
@@ -53,6 +53,7 @@ defmodule Mob.NifSchedulingCompletenessTest do
device_network_state device_os_version
device_set_dispatcher device_thermal_state exit_app files_pick haptic log
motion_start motion_stop native_stats_enable open_settings open_url
+ press_down_xy press_move_xy press_up_xy
platform post_mortem_android_drain post_mortem_ios_drain register_component register_tap
request_permission share_text storage_dir storage_external_files_dir
storage_save_to_media_store storage_save_to_photo_library
diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs
index d8c72e3..3f70104 100644
--- a/test/mob/renderer_test.exs
+++ b/test/mob/renderer_test.exs
@@ -349,6 +349,27 @@ defmodule Mob.RendererTest do
assert is_integer(decoded["props"]["on_double_tap"])
end
+ test "on_press_in / on_press_out register their own handles, carrying the tag" do
+ pid = self()
+
+ tree = %{
+ type: :box,
+ props: %{on_press_in: {pid, :mic}, on_press_out: {pid, :mic_up}},
+ children: []
+ }
+
+ Renderer.render(tree, :android, MockNIF)
+ {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end)
+ decoded = :json.decode(json)
+ assert is_integer(decoded["props"]["on_press_in"])
+ assert is_integer(decoded["props"]["on_press_out"])
+ assert decoded["props"]["on_press_in"] != decoded["props"]["on_press_out"]
+
+ registered = for {:register_tap, [target]} <- MockNIF.calls(), do: target
+ assert {pid, :mic} in registered
+ assert {pid, :mic_up} in registered
+ end
+
test "on_swipe and directional swipes are replaced by integer handles" do
pid = self()
diff --git a/test/mob/test_test.exs b/test/mob/test_test.exs
index fda7483..3a2dcbe 100644
--- a/test/mob/test_test.exs
+++ b/test/mob/test_test.exs
@@ -524,4 +524,24 @@ defmodule Mob.TestTest do
M.sample_color(:"nonexistent_mob_node@127.0.0.1", {0.0, 0.0, 10.0, 10.0})
end
end
+
+ describe "held press (press_down_xy/4 and friends)" do
+ test "a node whose mob has no press NIFs answers :not_loaded, not an RPC exit" do
+ # This node's :mob_nif never loads its library, exactly like a device
+ # running a mob older than MOB-380.
+ assert {:error, :not_loaded} = M.press_down_xy(node(), 10, 20)
+ assert {:error, :not_loaded} = M.press_move_xy(node(), 10, 20)
+ assert {:error, :not_loaded} = M.press_up_xy(node(), 10, 20)
+ end
+
+ test "hold_xy stops at a failed press_down instead of sleeping and lifting" do
+ {elapsed_us, result} = :timer.tc(fn -> M.hold_xy(node(), 10, 20, 2_000) end)
+ assert result == {:error, :not_loaded}
+ assert elapsed_us < 1_000_000
+ end
+
+ test "an unreachable node surfaces the dist failure" do
+ assert {:error, {:badrpc, _}} = M.press_down_xy(:"nonexistent_mob_node@127.0.0.1", 1, 2)
+ end
+ end
end