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
67 changes: 67 additions & 0 deletions android/jni/mob_nif.zig
Original file line numberDiff line numberDiff line change
Expand Up@@ -647,6 +647,72 @@ export fn nif_screenshot(
return erts.enif_make_binary(env, &bin);
}

// nif_capabilities/0 — which probes this app can actually serve.
//
// On Android a capability is a per-APP fact, not a per-platform one: every
// harness NIF is registered, and each returns {:error, :not_loaded} when the
// matching MobBridge method is absent from the cache. MobBridge.kt is
// app-owned and generated once, so an app built against an older template
// silently lacks methods a newer one has — the same drift that makes a
// hardcoded support table wrong within a release or two. Reading the cache
// reports what THIS app can do.
export fn nif_capabilities(
env: ?*erts.ErlNifEnv,
argc: c_int,
argv: [*]const erts.ERL_NIF_TERM,
) callconv(.c) erts.ERL_NIF_TERM {
_ = argc;
_ = argv;

// Keys and values are parallel arrays: keep them in the same order, and
// keep each value next to the NIF that actually serves that call.
const keys = [_]erts.ERL_NIF_TERM{
erts.atom(env, "view_tree"), erts.atom(env, "ui_tree"),
erts.atom(env, "screen_info"), erts.atom(env, "tap_xy"),
erts.atom(env, "tap_by_label"), erts.atom(env, "long_press_xy"),
erts.atom(env, "swipe_xy"), erts.atom(env, "type_text"),
erts.atom(env, "delete_backward"), erts.atom(env, "clear_text"),
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"),
};
const vals = [_]erts.ERL_NIF_TERM{
boolAtom(env, Bridge.ui_view_tree != null),
boolAtom(env, Bridge.ui_tree != null),
boolAtom(env, Bridge.screen_info != null),
boolAtom(env, Bridge.tap_xy != null),
boolAtom(env, Bridge.tap_by_label != null),
boolAtom(env, Bridge.long_press_xy != null),
boolAtom(env, Bridge.swipe_xy != null),
boolAtom(env, Bridge.type_text != null),
boolAtom(env, Bridge.delete_backward != null),
boolAtom(env, Bridge.clear_text != null),
// No accessibility-action bridge exists: nif_ax_action returns
// :not_supported_on_android unconditionally.
boolAtom(env, false),
boolAtom(env, Bridge.element_frames != null),
boolAtom(env, Bridge.scroll_info != null),
boolAtom(env, Bridge.scroll_to != null),
// Android has NO sample_region NIF — not a missing bridge method, an
// absent implementation. `sample_color/2` fails with a stub raise on
// this platform. Reporting it from Bridge.screenshot (the crop is
// served that way on iOS) claimed a capability that does not exist,
// which is worse than not reporting it at all: an agent commits to
// pixel verification and finds out mid-run.
// See decisions/2026-08-10-sample-region-crops-natively-and-stays-debug-only.md
boolAtom(env, false),
boolAtom(env, Bridge.screenshot != null),
};
return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error");
}

inline fn boolAtom(env: ?*erts.ErlNifEnv, on: bool) erts.ERL_NIF_TERM {
// Branch before the call: `erts.atom` takes the name as a comptime
// parameter, so selecting the string with a runtime `if` inside the
// argument does not compile.
return if (on) erts.atom(env, "true") else erts.atom(env, "false");
}

// nif_scroll_info/1 — read a scroll view's offset/extent (JSON string by :id).
export fn nif_scroll_info(
env: ?*erts.ErlNifEnv,
Expand DownExpand Up@@ -4191,6 +4257,7 @@ const nif_funcs = [_]erts.ErlNifFunc{
// Test harness first — matches the iOS nif_funcs[] ordering convention.
.{ .name = "ui_tree", .arity = 0, .fptr = nif_ui_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND },
.{ .name = "ui_view_tree", .arity = 0, .fptr = nif_ui_view_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND },
.{ .name = "capabilities", .arity = 0, .fptr = nif_capabilities, .flags = 0 },
.{ .name = "ax_action", .arity = 2, .fptr = nif_ax_action, .flags = 0 },
.{ .name = "ax_action_at_xy", .arity = 3, .fptr = nif_ax_action_at_xy, .flags = 0 },
.{ .name = "ui_debug", .arity = 0, .fptr = nif_ui_debug, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND },
Expand Down
48 changes: 48 additions & 0 deletions ios/mob_nif.m
Original file line numberDiff line numberDiff line change
Expand Up@@ -7065,6 +7065,53 @@ static ERL_NIF_TERM nif_element_frames(ErlNifEnv *env, int argc, const ERL_NIF_T

#endif // !MOB_RELEASE — end of test harness block (started near line 2780)

// ── Capabilities ──────────────────────────────────────────────────────────────
//
// Which probes this build can actually serve, so an agent can pick a strategy
// up front instead of discovering the answer by getting an error mid-run.
//
// Deliberately OUTSIDE the `#if !MOB_RELEASE` block above. A release build
// compiles the whole harness out, leaving the Erlang stubs in place — so a
// capabilities NIF inside the gate would itself be missing from exactly the
// build whose reduced capability it exists to report.
static ERL_NIF_TERM nif_capabilities(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
(void)argc;
(void)argv;

#if !MOB_RELEASE
int harness = 1;
#else
int harness = 0;
#endif

#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)
int screenshot = 1;
#else
int screenshot = 0;
#endif

ERL_NIF_TERM map = enif_make_new_map(env);

struct {
const char *name;
int on;
} caps[] = {
{"view_tree", harness}, {"ui_tree", harness}, {"screen_info", harness},
{"tap_xy", harness}, {"tap_by_label", harness}, {"long_press_xy", harness},
{"swipe_xy", harness}, {"type_text", harness}, {"delete_backward", harness},
{"clear_text", harness}, {"ax_action", harness}, {"element_frames", harness},
{"scroll_info", harness}, {"scroll_to", harness}, {"sample_region", harness},
{"screenshot", screenshot},
};

for (size_t i = 0; i < sizeof(caps) / sizeof(caps[0]); i++) {
enif_make_map_put(env, map, enif_make_atom(env, caps[i].name),
enif_make_atom(env, caps[i].on ? "true" : "false"), &map);
}

return map;
}

// ── Storage ───────────────────────────────────────────────────────────────────

static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
Expand DownExpand Up@@ -7954,6 +8001,7 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF
{"long_press_xy", 3, nif_long_press_xy, 0},
{"swipe_xy", 4, nif_swipe_xy, 0},
#endif
{"capabilities", 0, nif_capabilities, 0},
#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)
{"screenshot", 3, nif_screenshot, ERL_NIF_DIRTY_JOB_CPU_BOUND},
#endif
Expand Down
106 changes: 106 additions & 0 deletions lib/mob/test.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,16 @@ defmodule Mob.Test do
| `adjust_slider/4` | ⚠️ AX active§ | ⚠️ AX active§ | ❌ ui_tree_unavailable |
| `tap_xy/3` | ⚠️ AX-activatable only¶ | ❌ no_effect¶ | n/a |
| `swipe/5` | ⚠️ scroll only| ⚠️ unverified✱| n/a |
| `capabilities/1` | ✅ | ✅ | ✅ |

**This table is a snapshot, and snapshots drift.** Ask the running app
instead — `capabilities/1` reports what THIS build can actually serve. On
Android that is a per-*app* fact, since each harness NIF bails when its
cached `MobBridge` method is absent and the bridge is generated once and
never re-rendered; on iOS it is per-*configuration*, since the whole harness
is compiled out of release builds. Measured on a freshly generated Android
app, `tap_xy` and `type_text` are both unavailable (MOB-160) — the `n/a` in
the rows above reads as "not applicable" but means "no bridge method".

- **†** SwiftUI doesn't expose its content as separate UIView instances —
`view_tree` reaches the SwiftUI hosting view's container and stops.
Expand DownExpand Up@@ -388,6 +398,102 @@ defmodule Mob.Test do
end
end

@doc """
What this node can actually be probed with, right now.

Every helper in this module is a thin `:rpc.call` into `:mob_nif`, and which
of those the app can serve is a runtime fact, not a property of the platform:

* On **Android** each harness NIF checks a cached `MobBridge` method and
returns `{:error, :not_loaded}` when it is absent. `MobBridge.kt` is
app-owned and generated once, so an app built from an older template
silently lacks methods a newer one has.
* On **iOS** the whole harness is compiled out of release builds, leaving
the Erlang stubs behind.

Without this an agent finds out by running the probe and reading an error
mid-investigation, having already committed to an approach.

iex> Mob.Test.capabilities(node)
%{
dist_rpc: true,
view_tree: false,
tap_xy: true,
ax_action: false,
element_frames: true,
screenshot: true,
...
}

`dist_rpc` is `true` whenever the node answered, since that is what answering
proves. When it is unreachable every capability is `false` — including
against an iOS **release** build, which drops `-name` entirely and so has no
distribution to answer over.

A node whose `load_nif` failed reports `dist_rpc: true` with every probe
`false`: it answered, and every NIF really is down.

An app built before `mob_nif:capabilities/0` existed cannot answer. Rather
than guess from a table that would drift the same way, those report
`:unknown` for each probe with `dist_rpc: true` — the honest answer, and one
a caller can branch on.
"""
@spec capabilities(node(), timeout()) :: %{atom() => boolean() | :unknown}
def capabilities(node, timeout \\ 5_000) do
node
|> :rpc.call(:mob_nif, :capabilities, [], timeout)
|> classify_capabilities()
end

@doc false
# Extracted so the classification is testable without a device — every branch
# below describes a real state an agent hits, and the interesting ones cannot
# be produced from a host test otherwise.
@spec classify_capabilities(term()) :: %{atom() => boolean() | :unknown}
def classify_capabilities(%{} = caps), do: Map.put(caps, :dist_rpc, true)

def classify_capabilities({:badrpc, {:EXIT, {:undef, _}}}),
# The app predates `mob_nif:capabilities/0`. It answered, so dist works;
# what it can serve is genuinely unknown, and guessing from a table is the
# drift this function exists to avoid.
do: Map.put(unknown_probes(), :dist_rpc, true)

def classify_capabilities({:badrpc, {:EXIT, {:not_loaded, _}}}),
# `load_nif` failed on the device, so EVERY NIF is down, not just this one.
# Reporting `:unknown` would send an agent off to try probes that cannot
# work; false is the truth here.
do: unreachable() |> Map.put(:dist_rpc, true)

def classify_capabilities(_unreachable_or_unrecognised), do: unreachable()

@probe_keys [
:view_tree,
:ui_tree,
:screen_info,
:tap_xy,
:tap_by_label,
:long_press_xy,
:swipe_xy,
:type_text,
:delete_backward,
:clear_text,
:ax_action,
:element_frames,
:scroll_info,
:scroll_to,
:sample_region,
:screenshot
]

@doc false
@spec probe_keys() :: [atom()]
def probe_keys, do: @probe_keys

defp unknown_probes, do: Map.new(@probe_keys, &{&1, :unknown})

defp unreachable,
do: @probe_keys |> Map.new(&{&1, false}) |> Map.put(:dist_rpc, false)

@doc """
Switch to a named tab stack. Synchronous.

Expand Down
3 changes: 3 additions & 0 deletions src/mob_nif.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@
%% Test harness — native UI inspection and interaction
ui_tree/0,
ui_view_tree/0,
capabilities/0,
ui_paint_debug/0,
ui_debug/0,
screen_info/0,
Expand DownExpand Up@@ -189,6 +190,7 @@
device_keep_awake/1,
ui_tree/0,
ui_view_tree/0,
capabilities/0,
ui_paint_debug/0,
ui_debug/0,
screen_info/0,
Expand DownExpand Up@@ -323,6 +325,7 @@ ui_tree() -> erlang:nif_error(not_loaded).
%% bg_color/text_color are the colours the view actually painted, as
%% 0xAARRGGBB integers (see guides/theming.md), or nil.
ui_view_tree() -> erlang:nif_error(not_loaded).
capabilities() -> erlang:nif_error(not_loaded).
%% ui_paint_debug() -> JSON binary censusing where colour lives in the native
%% view tree, grouped by view/layer class. Diagnostic for when ui_view_tree
%% reports nil colours — tells you which property the renderer actually set.
Expand Down
Loading
Loading