diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 56781ff..4b899dd 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -119,6 +119,19 @@ void mob_set_launch_notification(const char* json); void mob_deliver_webview_message(jlong pid, const char* json); void mob_deliver_webview_blocked(jlong pid, const char* url); +// Deliver vendor_usb (Mob.VendorUsb / USB host) events. Each builds a 5-tuple +// {:peripheral, :vendor_usb, tag, session, payload} and posts it to pid. +// devices / permission / opened carry a JSON binary, decoded Elixir-side. +void mob_deliver_vendor_usb_devices(jlong pid, const char* json_array); +void mob_deliver_vendor_usb_permission(jlong pid, int granted, const char* device_json); +void mob_deliver_vendor_usb_opened(jlong pid, int session, const char* device_json); +void mob_deliver_vendor_usb_data(jlong pid, int session, + const uint8_t* bytes, size_t nbytes); +void mob_deliver_vendor_usb_write_complete(jlong pid, int session, int bytes_written); +void mob_deliver_vendor_usb_event(jlong pid, int session, + const char* tag, // "closed" | "disconnected" | "error" + const char* reason); // atom-safe ASCII or NULL + // Deliver {:alert, action_atom} to the registered :mob_screen process. // Called from beam_jni.c when a dialog button is tapped. void mob_deliver_alert_action(const char* action); diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c index c4d2c65..8498096 100644 --- a/android/jni/mob_nif.c +++ b/android/jni/mob_nif.c @@ -68,6 +68,14 @@ static struct { jmethodID storage_external_files_dir; jmethodID background_keep_alive; jmethodID background_stop; + // ── Peripheral.VendorUsb ───────────────────────────────────────────────── + jmethodID vendor_usb_list_devices; + jmethodID vendor_usb_request_permission; + jmethodID vendor_usb_open; + jmethodID vendor_usb_bulk_write; + jmethodID vendor_usb_start_reading; + jmethodID vendor_usb_stop_reading; + jmethodID vendor_usb_close; // Cached before nif_load (used during BEAM startup before NIFs are loaded) jmethodID set_startup_phase; jmethodID set_startup_error; @@ -2086,6 +2094,223 @@ static ERL_NIF_TERM nif_device_model(ErlNifEnv* env, int argc, const ERL_NIF_TER return enif_make_string(env, "Android", ERL_NIF_LATIN1); } +// ── Peripheral.VendorUsb ────────────────────────────────────────────────────── +// +// Six typed delivery functions, called from JNI thunks (in beam_jni.c) when +// Kotlin-side USB events fire. They build a 5-tuple +// {:peripheral, :vendor_usb, tag, session, payload} and send it to pid. +// +// session==-1 → atom :nil; session>=0 → integer. +// +// devices/permission/opened tags carry a JSON binary payload that the Elixir +// side decodes via Mob.Peripheral.VendorUsb.normalize_message/1 (mirrors the +// :mob_file_result JSON-binary precedent for camera/photos/files/audio/scan). + +static ERL_NIF_TERM make_session_term(ErlNifEnv* e, int session) { + return session < 0 ? enif_make_atom(e, "nil") : enif_make_int(e, session); +} + +void mob_deliver_vendor_usb_devices(jlong jpid, const char* json_array) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ErlNifBinary jb; + size_t jl = json_array ? strlen(json_array) : 0; + enif_alloc_binary(jl, &jb); + if (jl) memcpy(jb.data, json_array, jl); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "devices_json"), + enif_make_atom(e, "nil"), + enif_make_binary(e, &jb)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +void mob_deliver_vendor_usb_permission(jlong jpid, int granted, const char* device_json) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ErlNifBinary jb; + size_t jl = device_json ? strlen(device_json) : 0; + enif_alloc_binary(jl, &jb); + if (jl) memcpy(jb.data, device_json, jl); + ERL_NIF_TERM tag = enif_make_atom(e, + granted ? "permission_granted_json" : "permission_denied_json"); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + tag, + enif_make_atom(e, "nil"), + enif_make_binary(e, &jb)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +void mob_deliver_vendor_usb_opened(jlong jpid, int session, const char* device_json) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ErlNifBinary jb; + size_t jl = device_json ? strlen(device_json) : 0; + enif_alloc_binary(jl, &jb); + if (jl) memcpy(jb.data, device_json, jl); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "opened_json"), + make_session_term(e, session), + enif_make_binary(e, &jb)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +void mob_deliver_vendor_usb_data(jlong jpid, int session, + const uint8_t* bytes, size_t nbytes) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ErlNifBinary db; + enif_alloc_binary(nbytes, &db); + if (nbytes && bytes) memcpy(db.data, bytes, nbytes); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "data"), + make_session_term(e, session), + enif_make_binary(e, &db)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +void mob_deliver_vendor_usb_write_complete(jlong jpid, int session, int bytes_written) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ERL_NIF_TERM bytes_map = enif_make_new_map(e); + ERL_NIF_TERM tmp; + enif_make_map_put(e, bytes_map, + enif_make_atom(e, "bytes"), + enif_make_int(e, bytes_written), + &tmp); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "write_complete"), + make_session_term(e, session), + tmp); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +void mob_deliver_vendor_usb_event(jlong jpid, int session, + const char* tag, const char* reason) { + ErlNifPid pid = pid_from_long(jpid); + ErlNifEnv* e = enif_alloc_env(); + ERL_NIF_TERM payload = reason + ? enif_make_atom(e, reason) + : enif_make_atom(e, "ok"); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, tag ? tag : "error"), + make_session_term(e, session), + payload); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +// ── Vendor-USB NIFs (thin wrappers over Kotlin static methods) ──────────────── + +static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char* json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); json[bin.size] = 0; + ErlNifPid pid; enif_self(env, &pid); + ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.vendor_usb_list_devices, pid, json); + free(json); + return result; +} + +static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char* ref = malloc(bin.size + 1); + memcpy(ref, bin.data, bin.size); ref[bin.size] = 0; + ErlNifPid pid; enif_self(env, &pid); + ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.vendor_usb_request_permission, pid, ref); + free(ref); + return result; +} + +static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + char* json = malloc(bin.size + 1); + memcpy(json, bin.data, bin.size); json[bin.size] = 0; + ErlNifPid pid; enif_self(env, &pid); + ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.vendor_usb_open, pid, json); + free(json); + return result; +} + +static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + int session = 0; int timeout_ms = 1000; + if (!enif_get_int(env, argv[0], &session)) return enif_make_badarg(env); + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[1], &bin) && + !enif_inspect_iolist_as_binary(env, argv[1], &bin)) + return enif_make_badarg(env); + if (!enif_get_int(env, argv[2], &timeout_ms)) return enif_make_badarg(env); + + ErlNifPid pid; enif_self(env, &pid); + int att; JNIEnv* jenv = get_jenv(&att); + jlong jpid; + memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); + jbyteArray jbytes = (*jenv)->NewByteArray(jenv, (jsize)bin.size); + (*jenv)->SetByteArrayRegion(jenv, jbytes, 0, (jsize)bin.size, (const jbyte*)bin.data); + (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.vendor_usb_bulk_write, + jpid, (jint)session, jbytes, (jint)timeout_ms); + (*jenv)->DeleteLocalRef(jenv, jbytes); + if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + int session = 0, chunk_bytes = 4096; + if (!enif_get_int(env, argv[0], &session)) return enif_make_badarg(env); + if (!enif_get_int(env, argv[1], &chunk_bytes)) return enif_make_badarg(env); + ErlNifPid pid; enif_self(env, &pid); + int att; JNIEnv* jenv = get_jenv(&att); + jlong jpid; + memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); + (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.vendor_usb_start_reading, + jpid, (jint)session, (jint)chunk_bytes); + if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + int session = 0; + if (!enif_get_int(env, argv[0], &session)) return enif_make_badarg(env); + int att; JNIEnv* jenv = get_jenv(&att); + (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.vendor_usb_stop_reading, (jint)session); + if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + int session = 0; + if (!enif_get_int(env, argv[0], &session)) return enif_make_badarg(env); + int att; JNIEnv* jenv = get_jenv(&att); + (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.vendor_usb_close, (jint)session); + if (att) (*g_jvm)->DetachCurrentThread(g_jvm); + return enif_make_atom(env, "ok"); +} + // ── NIF table & load ────────────────────────────────────────────────────────── // Scheduling notes — see docs/decisions/0001-dirty-nifs.md for the rationale. @@ -2172,6 +2397,14 @@ static ErlNifFunc nif_funcs[] = { {"device_foreground", 0, nif_device_foreground, 0}, {"device_os_version", 0, nif_device_os_version, 0}, {"device_model", 0, nif_device_model, 0}, + // ── Mob.Peripheral.VendorUsb (Android USB host) ─────────────────────────── + {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, + {"vendor_usb_request_permission",1, nif_vendor_usb_request_permission,0}, + {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, + {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, + {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, + {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, }; static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { @@ -2256,6 +2489,14 @@ static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { CACHE(notify_register_push, "(JLjava/lang/String;)V") CACHE(background_keep_alive, "()V") CACHE(background_stop, "()V") + // ── Mob.Peripheral.VendorUsb ────────────────────────────────────────────── + CACHE(vendor_usb_list_devices, "(JLjava/lang/String;)V") + CACHE(vendor_usb_request_permission, "(JLjava/lang/String;)V") + CACHE(vendor_usb_open, "(JLjava/lang/String;)V") + CACHE(vendor_usb_bulk_write, "(JI[BI)V") + CACHE(vendor_usb_start_reading, "(JII)V") + CACHE(vendor_usb_stop_reading, "(I)V") + CACHE(vendor_usb_close, "(I)V") #undef CACHE g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 40a9f38..48cdd9b 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5039,6 +5039,64 @@ void mob_send_component_event(int handle, const char* event, const char* payload // some pre-dispatch work; they're left on regular schedulers for now because // the test harness calls them in tight loops and dirty-dispatch overhead would // add up. Re-evaluate if benchmarks show scheduler stalls under heavy harness use. + +// ── Mob.Peripheral.VendorUsb (iOS stubs) ────────────────────────────────────── +// +// iOS exposes no public USB-host API equivalent to Android's UsbManager. +// All seven NIFs below send {:peripheral, :vendor_usb, :error, nil, :unsupported} +// back to the caller and return :ok. Cross-platform screens see the error +// event and degrade gracefully via Mob.Peripheral.capabilities/0. + +static void send_vendor_usb_unsupported(ErlNifPid pid) { + ErlNifEnv* e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), + enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), + enif_make_atom(e, "unsupported")); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + return enif_make_atom(env, "ok"); +} + static ErlNifFunc nif_funcs[] = { #if !MOB_RELEASE // ── Test harness (listed first to survive linker dead-code stripping) ────── @@ -5124,6 +5182,14 @@ void mob_send_component_event(int handle, const char* event, const char* payload {"webview_go_back", 0, nif_webview_go_back, 0}, {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, + // ── Mob.Peripheral.VendorUsb (iOS stubs — emit :unsupported) ────────────── + {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, + {"vendor_usb_request_permission", 1, nif_vendor_usb_request_permission, 0}, + {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, + {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, 0}, + {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, + {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, + {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, }; static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 45ca0dc..1bd138c 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -384,6 +384,16 @@ defmodule Mob.Screen do handle_info(msg, state) end + # Peripheral.* events: a few carry JSON-encoded device records under tags + # like `:devices_json`, `:permission_granted_json`, etc. The transport's + # own module knows how to decode them; we dispatch through its + # `normalize_message/1` (a no-op for events without JSON payloads) before + # the user's handle_info sees them. + def handle_info({:peripheral, :vendor_usb, _tag, _session, _payload} = msg, state) do + normalized = Mob.VendorUsb.normalize_message(msg) + handle_info(normalized, state) + end + # System back gesture (Android hardware/swipe, iOS edge-pan). # Handled here — before the user's handle_info — so every screen gets back # navigation for free without implementing anything. diff --git a/lib/mob/vendor_usb.ex b/lib/mob/vendor_usb.ex new file mode 100644 index 0000000..63408af --- /dev/null +++ b/lib/mob/vendor_usb.ex @@ -0,0 +1,333 @@ +defmodule Mob.VendorUsb do + @moduledoc """ + Raw USB host access via vendor bulk endpoints. **Android only.** + + No permission required at the OS-permission level, but Android prompts the + user to grant per-device access via the system dialog when you call + `request_permission/2`. The grant is per app + device + session; granting + "always" only sticks if the user ticks the checkbox. + + iOS calls return the socket unchanged and emit + `{:peripheral, :vendor_usb, :error, nil, :unsupported}`. See + `Mob.Ble` for iOS-friendly equivalent transports. + the (forthcoming) `Mob.Midi` or `Mob.Ble`. + + ## Lifecycle + + ``` + list_devices/1 → {:peripheral, :vendor_usb, :devices, _, [device, …]} + request_permission/2 → {:peripheral, :vendor_usb, :permission_granted, _, device} + {:peripheral, :vendor_usb, :permission_denied, _, device} + open/2 → {:peripheral, :vendor_usb, :opened, session, device} + {:peripheral, :vendor_usb, :error, nil, reason} + bulk_write/4 → {:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}} + (or :error for failures) + start_reading/3 → {:peripheral, :vendor_usb, :data, session, binary} + (delivered repeatedly; use stop_reading/2 to halt) + stop_reading/2 + close/2 → {:peripheral, :vendor_usb, :closed, session, reason} + ``` + + Any unsolicited `{:peripheral, :vendor_usb, :disconnected, session, reason}` + may arrive at any time (cable unplug, device removed). After + `:disconnected`, the session handle is dead — drop your reference and call + `list_devices/1` again to reacquire. + + ## Example: a USB echo demo + + This shape works for any USB device that exposes bulk IN/OUT + endpoints. Substitute the VID/PID and frame format for your device. + + defmodule MyApp.UsbScreen do + use Mob.Screen + alias Mob.VendorUsb + + @my_vid 0x1234 + @my_pid 0x5678 + + def mount(_p, _s, socket) do + {:ok, + socket + |> Mob.Socket.assign(:devices, []) + |> Mob.Socket.assign(:session, nil) + |> VendorUsb.list_devices(vendor_id: @my_vid)} + end + + def handle_info({:peripheral, :vendor_usb, :devices, _, devices}, socket) do + {:noreply, Mob.Socket.assign(socket, :devices, devices)} + end + + def handle_info({:peripheral, :vendor_usb, :permission_granted, _, dev}, socket) do + {:noreply, VendorUsb.open(socket, dev, interface: 0)} + end + + def handle_info({:peripheral, :vendor_usb, :opened, session, _dev}, socket) do + socket = + socket + |> Mob.Socket.assign(:session, session) + |> VendorUsb.start_reading(session) + |> VendorUsb.bulk_write(session, "hello") + + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :data, _session, binary}, socket) do + IO.inspect(binary, label: "from device") + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :disconnected, _, _}, socket) do + {:noreply, Mob.Socket.assign(socket, :session, nil)} + end + end + + ## Framing is your problem + + This module is byte-level. USB bulk endpoints do *not* preserve message + boundaries — the bytes you wrote in one `bulk_write/4` call may arrive + on the other end split across multiple chunks, or coalesced with later + writes. Likewise, `:data` events deliver whatever the OS happens to + hand back from a read; do not assume one event corresponds to one + logical message. + + If your device uses a framed protocol (length-prefix, COBS, SLIP, + delimiters, fixed-size records), implement the framer in a layer + above this one. A reasonable pattern is a `GenServer` that owns the + session, accumulates incoming chunks into a buffer, and drains + complete frames out for higher-level consumers. + + ## Device shape + + Devices arrive as maps: + + %{ + vendor_id: 0x1234, + product_id: 0x5678, + manufacturer: "Acme Inc.", + product: "Widget 9000", + serial: "SN-000001", + # opaque handle the OS uses to refer to this device. Treat as a + # binary; do not parse. Pass back to `request_permission/2` etc. + ref: "/dev/bus/usb/001/002" + } + + ## Session handles + + `open/2` delivers an integer session handle. Session handles are valid + until `:disconnected` or `close/2`. They are *not* persistent across app + restarts — re-enumerate after launch. + + ## Buffer ownership + + Binaries you pass to `bulk_write/4` are copied into a native-side buffer + before the NIF returns. Binaries delivered via `:data` are owned by the + BEAM — they will outlive the underlying USB read buffer. + + ## Limits + + Maximum write size per call: 16 KiB. Larger writes are rejected with + `{:error, :payload_too_large}`. Read chunks are bounded by the USB max + packet size for the endpoint (typically 64 B Full Speed, 512 B High + Speed); the native read loop coalesces packets into BEAM-side binaries + bounded by `:read_chunk_bytes` (default 4 KiB). + """ + + @type device :: %{ + vendor_id: non_neg_integer(), + product_id: non_neg_integer(), + manufacturer: String.t() | nil, + product: String.t() | nil, + serial: String.t() | nil, + ref: String.t() + } + + @type session :: integer() + + @max_write_bytes 16 * 1024 + + @doc """ + Enumerate connected USB devices. + + Result: `{:peripheral, :vendor_usb, :devices, nil, [device, …]}` + + Options: + * `:vendor_id` — filter to a single VID + * `:product_id` — filter to a single PID (only meaningful with VID) + + Filtering happens native-side; an empty result is a real "no matching + device", not a permission/availability issue. + """ + @spec list_devices(Mob.Socket.t(), keyword()) :: Mob.Socket.t() + def list_devices(socket, opts \\ []) do + filter = + %{} + |> maybe_put_filter("vendor_id", Keyword.get(opts, :vendor_id)) + |> maybe_put_filter("product_id", Keyword.get(opts, :product_id)) + + json = :json.encode(filter) + :mob_nif.vendor_usb_list_devices(json) + socket + end + + defp maybe_put_filter(map, _key, nil), do: map + defp maybe_put_filter(map, key, val), do: Map.put(map, key, val) + + @doc """ + Ask the OS to prompt the user to grant access to a specific device. + + `device` is the map returned by `list_devices/1`. Only the `:ref` field + is consulted, but it is convenient to pass the whole map. + + Result: + * `{:peripheral, :vendor_usb, :permission_granted, nil, device}` + * `{:peripheral, :vendor_usb, :permission_denied, nil, device}` + + Idempotent. If the user has already granted access, the granted message + fires immediately without showing a dialog. + """ + @spec request_permission(Mob.Socket.t(), device()) :: Mob.Socket.t() + def request_permission(socket, %{ref: ref} = _device) when is_binary(ref) do + :mob_nif.vendor_usb_request_permission(ref) + socket + end + + @doc """ + Open a permitted device and claim an interface. + + Options: + * `:interface` — interface number (default `0`) + * `:endpoint_in` — bulk IN endpoint address (e.g. `0x81`); if omitted, + the first bulk IN endpoint on the interface is auto-selected + * `:endpoint_out` — bulk OUT endpoint address (e.g. `0x01`); if + omitted, the first bulk OUT endpoint on the interface is + auto-selected + + Result: + * `{:peripheral, :vendor_usb, :opened, session, device}` + * `{:peripheral, :vendor_usb, :error, nil, reason}` — common reasons: + `:no_permission`, `:device_gone`, `:interface_busy`, + `:no_bulk_endpoints` + """ + @spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t() + def open(socket, %{ref: ref}, opts \\ []) when is_binary(ref) do + fields = + %{"ref" => ref, "interface" => Keyword.get(opts, :interface, 0)} + |> maybe_put_filter("endpoint_in", Keyword.get(opts, :endpoint_in)) + |> maybe_put_filter("endpoint_out", Keyword.get(opts, :endpoint_out)) + + json = :json.encode(fields) + :mob_nif.vendor_usb_open(json) + socket + end + + @doc """ + Send bytes to the device's bulk OUT endpoint. + + `data` may be a binary or iolist; it is flattened and copied native-side + before the NIF returns. Maximum size: #{@max_write_bytes} bytes. + + Options: + * `:timeout_ms` — write timeout (default `1000`) + + Result: + * `{:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}` + * `{:peripheral, :vendor_usb, :error, session, reason}` + """ + @spec bulk_write(Mob.Socket.t(), session(), iodata(), keyword()) :: Mob.Socket.t() + def bulk_write(socket, session, data, opts \\ []) when is_integer(session) do + bin = IO.iodata_to_binary(data) + + cond do + byte_size(bin) == 0 -> + socket + + byte_size(bin) > @max_write_bytes -> + send(self(), {:peripheral, :vendor_usb, :error, session, :payload_too_large}) + socket + + true -> + timeout = Keyword.get(opts, :timeout_ms, 1000) + :mob_nif.vendor_usb_bulk_write(session, bin, timeout) + socket + end + end + + @doc """ + Start a continuous read loop on the bulk IN endpoint. + + After this call, every chunk read native-side is delivered as + `{:peripheral, :vendor_usb, :data, session, binary}` to the calling + process. Stop with `stop_reading/2`. + + Options: + * `:read_chunk_bytes` — soft cap on per-message coalescing (default + `4096`). Smaller values reduce latency; larger reduce overhead. + + Idempotent: calling twice is a no-op. + """ + @spec start_reading(Mob.Socket.t(), session(), keyword()) :: Mob.Socket.t() + def start_reading(socket, session, opts \\ []) when is_integer(session) do + chunk = Keyword.get(opts, :read_chunk_bytes, 4096) + :mob_nif.vendor_usb_start_reading(session, chunk) + socket + end + + @doc "Stop the read loop started by `start_reading/3`." + @spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t() + def stop_reading(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_stop_reading(session) + socket + end + + @doc """ + Close a device session, releasing the interface and freeing the file + descriptor. Idempotent. Always emits + `{:peripheral, :vendor_usb, :closed, session, :ok}`. + """ + @spec close(Mob.Socket.t(), session()) :: Mob.Socket.t() + def close(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_close(session) + socket + end + + # ── Event normalization ──────────────────────────────────────────────── + # + # The Android NIF delivers a few high-cardinality events with their + # payloads as JSON binaries (`:devices_json`, `:permission_granted_json`, + # `:permission_denied_json`, `:opened_json`) to keep the C/JNI side + # simple. `Mob.Screen` calls `normalize_message/1` once before the + # screen's `handle_info/2` runs, so user code only sees the public event + # shape documented at the top of this module. + + @doc false + @spec normalize_message(term()) :: term() + def normalize_message({:peripheral, :vendor_usb, :devices_json, _, json}) when is_binary(json) do + devices = json |> :json.decode() |> Enum.map(&device_from_map/1) + {:peripheral, :vendor_usb, :devices, nil, devices} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_granted_json, _, json}) do + {:peripheral, :vendor_usb, :permission_granted, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_denied_json, _, json}) do + {:peripheral, :vendor_usb, :permission_denied, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :opened_json, session, json}) do + {:peripheral, :vendor_usb, :opened, session, device_from_map(:json.decode(json))} + end + + def normalize_message(other), do: other + + defp device_from_map(map) when is_map(map) do + %{ + vendor_id: Map.get(map, "vendor_id"), + product_id: Map.get(map, "product_id"), + manufacturer: Map.get(map, "manufacturer"), + product: Map.get(map, "product"), + serial: Map.get(map, "serial"), + ref: Map.get(map, "ref") + } + end +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index bcfd676..408bac5 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -94,7 +94,15 @@ key_press/1, clear_text/0, long_press_xy/3, - swipe_xy/4]). + swipe_xy/4, + %% Peripheral.VendorUsb (Android USB host; iOS returns :unsupported) + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1]). -nifs([platform/0, color_scheme/0, @@ -173,7 +181,15 @@ webview_go_back/0, %% Native view components register_component/1, - deregister_component/1]). + deregister_component/1, + %% Peripheral.VendorUsb + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1]). -on_load(init/0). @@ -254,3 +270,11 @@ webview_can_go_back() -> erlang:nif_error(not_loaded). webview_go_back() -> erlang:nif_error(not_loaded). register_component(_Pid) -> erlang:nif_error(not_loaded). deregister_component(_Handle) -> erlang:nif_error(not_loaded). +%% Peripheral.VendorUsb +vendor_usb_list_devices(_FilterJson) -> erlang:nif_error(not_loaded). +vendor_usb_request_permission(_Ref) -> erlang:nif_error(not_loaded). +vendor_usb_open(_OptsJson) -> erlang:nif_error(not_loaded). +vendor_usb_bulk_write(_Session, _Bytes, _TimeoutMs) -> erlang:nif_error(not_loaded). +vendor_usb_start_reading(_Session, _ChunkBytes) -> erlang:nif_error(not_loaded). +vendor_usb_stop_reading(_Session) -> erlang:nif_error(not_loaded). +vendor_usb_close(_Session) -> erlang:nif_error(not_loaded). diff --git a/test/mob/vendor_usb_test.exs b/test/mob/vendor_usb_test.exs new file mode 100644 index 0000000..2d186ac --- /dev/null +++ b/test/mob/vendor_usb_test.exs @@ -0,0 +1,129 @@ +defmodule Mob.VendorUsbTest do + use ExUnit.Case, async: true + + alias Mob.VendorUsb + + describe "normalize_message/1 — devices_json" do + test "decodes a list of device records" do + json = + IO.iodata_to_binary(:json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "manufacturer" => "Acme Inc.", + "product" => "Widget 9000", + "serial" => "SN-000001", + "ref" => "/dev/bus/usb/001/002" + } + ])) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :devices_json, nil, json} + ) + + assert device.vendor_id == 0x1234 + assert device.product_id == 0x5678 + assert device.manufacturer == "Acme Inc." + assert device.product == "Widget 9000" + assert device.serial == "SN-000001" + assert device.ref == "/dev/bus/usb/001/002" + end + + test "empty list passes through cleanly" do + assert {:peripheral, :vendor_usb, :devices, nil, []} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :devices_json, nil, IO.iodata_to_binary(:json.encode([]))} + ) + end + + test "tolerates missing optional string fields" do + json = + IO.iodata_to_binary(:json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + } + ])) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :devices_json, nil, json} + ) + + assert device.manufacturer == nil + assert device.product == nil + assert device.serial == nil + end + end + + describe "normalize_message/1 — permission events" do + test "permission_granted_json becomes :permission_granted with a device map" do + json = + IO.iodata_to_binary(:json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + })) + + assert {:peripheral, :vendor_usb, :permission_granted, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_granted_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + + test "permission_denied_json becomes :permission_denied" do + json = IO.iodata_to_binary(:json.encode(%{"ref" => "/dev/bus/usb/001/002"})) + + assert {:peripheral, :vendor_usb, :permission_denied, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_denied_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + end + + describe "normalize_message/1 — opened_json" do + test "decodes opened with session id and device payload" do + json = + IO.iodata_to_binary(:json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + })) + + assert {:peripheral, :vendor_usb, :opened, 7, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :opened_json, 7, json} + ) + + assert device.vendor_id == 0x1234 + end + end + + describe "normalize_message/1 — passthrough" do + test "non-JSON peripheral events pass through unchanged" do + msg = {:peripheral, :vendor_usb, :data, 7, <<1, 2, 3>>} + assert VendorUsb.normalize_message(msg) == msg + end + + test "write_complete passes through unchanged" do + msg = {:peripheral, :vendor_usb, :write_complete, 7, %{bytes: 4}} + assert VendorUsb.normalize_message(msg) == msg + end + + test "error event passes through unchanged" do + msg = {:peripheral, :vendor_usb, :error, 7, :write_timeout} + assert VendorUsb.normalize_message(msg) == msg + end + + test "unrelated messages pass through unchanged" do + msg = {:something, :else} + assert VendorUsb.normalize_message(msg) == msg + end + end +end