diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 0343f86f..4eb79a94 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -140,4 +140,65 @@ void mob_send_component_event(int handle, const char *event, const char *payload // `scheme` must be "light" or "dark". void mob_send_color_scheme_changed(const char *scheme); + +// mob_beam.h additions for Mob.Bt +// +// Append these to the existing mob_beam.h, after the +// mob_deliver_vendor_usb_* block. Order matches the +// `pub export fn mob_deliver_bt_*` definitions in mob_nif.zig. +// +// All BT deliveries take a jlong pid (ErlNifPid round-tripped through +// Kotlin) and post a 4-tuple `{:bt|:bt_hfp|:bt_spp|:bt_hid, tag, session_or_nil, payload}` +// to that pid. Payload shape varies by event; see mob_nif.zig for details. + +// ── Discovery (no-payload 2-tuples) ────────────────────────────────────── +void mob_deliver_bt_discovery_started(jlong pid); +void mob_deliver_bt_discovery_finished(jlong pid); +void mob_deliver_bt_discovery_cancelled(jlong pid); + +// ── Discovery / pairing events (3-tuples, no session) ──────────────────── +void mob_deliver_bt_discovered(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_pair_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_unpaired(jlong pid, const char *address); +void mob_deliver_bt_error(jlong pid, const char *reason); + +// ── Legacy JSON paired-devices envelope (compat with older mob_new templates) ── +void mob_deliver_bt_paired_devices(jlong pid, const char *json); + +// ── Paired-list streaming (begin / entry / finish) ────────────────────── +// Kotlin invokes begin, then 0..N entry calls, then finish. The finish +// call emits a single `{:bt, :paired_list, list}` to the originating pid. +void mob_deliver_bt_paired_list_begin(jlong pid); +void mob_deliver_bt_paired_list_entry(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired_list_finish(jlong pid); + +// ── HFP profile (8 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hfp_connecting(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_hfp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hfp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hfp_vendor_subscribed(jlong pid, int session); +void mob_deliver_bt_hfp_vendor_at(jlong pid, int session, const char *cmd, int cmd_type, + const char *args, const char *address); +void mob_deliver_bt_hfp_sco_started(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_sco_stopped(jlong pid, int session); +void mob_deliver_bt_hfp_sco_audio(jlong pid, int session, const char *pcm, size_t len); +void mob_deliver_bt_hfp_error(jlong pid, int session, const char *reason); + +// ── SPP profile (6 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_spp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_spp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_spp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_spp_data(jlong pid, int session, const char *bytes, size_t len); +void mob_deliver_bt_spp_written(jlong pid, int session, int size); +void mob_deliver_bt_spp_error(jlong pid, int session, const char *reason); + +// ── HID profile (5 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hid_connected(jlong pid, int session, const char *address); +void mob_deliver_bt_hid_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hid_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hid_input(jlong pid, int session, int type, int code, int value); +void mob_deliver_bt_hid_raw_report(jlong pid, int session, const char *bytes, size_t len); + #endif // MOB_BEAM_H diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index ac12cab6..e4e41ddb 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -210,6 +210,23 @@ pub const BridgeMethods = extern struct { vendor_usb_start_reading: jni.JMethodID = null, vendor_usb_stop_reading: jni.JMethodID = null, vendor_usb_close: jni.JMethodID = null, + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + bt_list_paired: jni.JMethodID = null, + bt_start_discovery: jni.JMethodID = null, + bt_cancel_discovery: jni.JMethodID = null, + bt_pair: jni.JMethodID = null, + bt_unpair: jni.JMethodID = null, + bt_disconnect: jni.JMethodID = null, + bt_hfp_connect: jni.JMethodID = null, + bt_hfp_subscribe_vendor_at: jni.JMethodID = null, + bt_hfp_send_vendor_at: jni.JMethodID = null, + bt_hfp_start_sco: jni.JMethodID = null, + bt_hfp_stop_sco: jni.JMethodID = null, + bt_hfp_send_audio: jni.JMethodID = null, + bt_spp_connect: jni.JMethodID = null, + bt_spp_write: jni.JMethodID = null, + bt_hid_connect: jni.JMethodID = null, + bt_hid_subscribe_raw: jni.JMethodID = null, }; /// Exported with C ABI so mob_nif.c (and beam_jni.c for the senders in @@ -3080,6 +3097,1343 @@ export fn nif_vendor_usb_close( return erts.ok(env); } +// ═════════════════════════════════════════════════════════════════════════ +// Mob.Bt (Bluetooth Classic) — atom cache + delivery functions + NIFs +// ═════════════════════════════════════════════════════════════════════════ + +const MobBtAtoms = struct { + // Channel atoms + bt: erts.ERL_NIF_TERM = 0, + bt_hfp: erts.ERL_NIF_TERM = 0, + bt_spp: erts.ERL_NIF_TERM = 0, + bt_hid: erts.ERL_NIF_TERM = 0, + + // Discovery / pairing tags + discovery_started: erts.ERL_NIF_TERM = 0, + discovery_finished: erts.ERL_NIF_TERM = 0, + discovery_cancelled: erts.ERL_NIF_TERM = 0, + discovered: erts.ERL_NIF_TERM = 0, + paired_list: erts.ERL_NIF_TERM = 0, + paired: erts.ERL_NIF_TERM = 0, + pair_failed: erts.ERL_NIF_TERM = 0, + unpaired: erts.ERL_NIF_TERM = 0, + err: erts.ERL_NIF_TERM = 0, + + // Profile lifecycle tags + connecting: erts.ERL_NIF_TERM = 0, + connected: erts.ERL_NIF_TERM = 0, + connect_failed: erts.ERL_NIF_TERM = 0, + disconnected: erts.ERL_NIF_TERM = 0, + + // HFP-specific + vendor_subscribed: erts.ERL_NIF_TERM = 0, + vendor_at: erts.ERL_NIF_TERM = 0, + sco_started: erts.ERL_NIF_TERM = 0, + sco_stopped: erts.ERL_NIF_TERM = 0, + sco_audio: erts.ERL_NIF_TERM = 0, + + // SPP-specific + data: erts.ERL_NIF_TERM = 0, + written: erts.ERL_NIF_TERM = 0, + + // HID-specific + input: erts.ERL_NIF_TERM = 0, + raw_report: erts.ERL_NIF_TERM = 0, + + // Map keys + k_address: erts.ERL_NIF_TERM = 0, + k_name: erts.ERL_NIF_TERM = 0, + k_bonded: erts.ERL_NIF_TERM = 0, + k_reason: erts.ERL_NIF_TERM = 0, + k_cmd: erts.ERL_NIF_TERM = 0, + k_cmd_type: erts.ERL_NIF_TERM = 0, + k_args: erts.ERL_NIF_TERM = 0, + k_size: erts.ERL_NIF_TERM = 0, + k_type: erts.ERL_NIF_TERM = 0, + k_code: erts.ERL_NIF_TERM = 0, + k_value: erts.ERL_NIF_TERM = 0, + + // Constants + nil_atom: erts.ERL_NIF_TERM = 0, + true_atom: erts.ERL_NIF_TERM = 0, + false_atom: erts.ERL_NIF_TERM = 0, +}; + +var mob_bt_atoms: MobBtAtoms = .{}; + +/// Initialise the BT atom cache. Call from `nifLoad` once before any +/// BT NIF or deliver function fires. +pub fn mobBtAtomsInit(env: ?*erts.ErlNifEnv) void { + mob_bt_atoms.bt = erts.atom(env, "bt"); + mob_bt_atoms.bt_hfp = erts.atom(env, "bt_hfp"); + mob_bt_atoms.bt_spp = erts.atom(env, "bt_spp"); + mob_bt_atoms.bt_hid = erts.atom(env, "bt_hid"); + + mob_bt_atoms.discovery_started = erts.atom(env, "discovery_started"); + mob_bt_atoms.discovery_finished = erts.atom(env, "discovery_finished"); + mob_bt_atoms.discovery_cancelled = erts.atom(env, "discovery_cancelled"); + mob_bt_atoms.discovered = erts.atom(env, "discovered"); + mob_bt_atoms.paired_list = erts.atom(env, "paired_list"); + mob_bt_atoms.paired = erts.atom(env, "paired"); + mob_bt_atoms.pair_failed = erts.atom(env, "pair_failed"); + mob_bt_atoms.unpaired = erts.atom(env, "unpaired"); + mob_bt_atoms.err = erts.atom(env, "error"); + + mob_bt_atoms.connecting = erts.atom(env, "connecting"); + mob_bt_atoms.connected = erts.atom(env, "connected"); + mob_bt_atoms.connect_failed = erts.atom(env, "connect_failed"); + mob_bt_atoms.disconnected = erts.atom(env, "disconnected"); + + mob_bt_atoms.vendor_subscribed = erts.atom(env, "vendor_subscribed"); + mob_bt_atoms.vendor_at = erts.atom(env, "vendor_at"); + mob_bt_atoms.sco_started = erts.atom(env, "sco_started"); + mob_bt_atoms.sco_stopped = erts.atom(env, "sco_stopped"); + mob_bt_atoms.sco_audio = erts.atom(env, "sco_audio"); + + mob_bt_atoms.data = erts.atom(env, "data"); + mob_bt_atoms.written = erts.atom(env, "written"); + + mob_bt_atoms.input = erts.atom(env, "input"); + mob_bt_atoms.raw_report = erts.atom(env, "raw_report"); + + mob_bt_atoms.k_address = erts.atom(env, "address"); + mob_bt_atoms.k_name = erts.atom(env, "name"); + mob_bt_atoms.k_bonded = erts.atom(env, "bonded"); + mob_bt_atoms.k_reason = erts.atom(env, "reason"); + mob_bt_atoms.k_cmd = erts.atom(env, "cmd"); + mob_bt_atoms.k_cmd_type = erts.atom(env, "cmd_type"); + mob_bt_atoms.k_args = erts.atom(env, "args"); + mob_bt_atoms.k_size = erts.atom(env, "size"); + mob_bt_atoms.k_type = erts.atom(env, "type"); + mob_bt_atoms.k_code = erts.atom(env, "code"); + mob_bt_atoms.k_value = erts.atom(env, "value"); + + mob_bt_atoms.nil_atom = erts.atom(env, "nil"); + mob_bt_atoms.true_atom = erts.atom(env, "true"); + mob_bt_atoms.false_atom = erts.atom(env, "false"); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (3) Term constructors — mirror the C mob_bt_make_* helpers +// ═════════════════════════════════════════════════════════════════════════ + +/// Convert a C string into an Erlang binary term. Empty input → empty +/// binary (NOT a `<<>>` atom dance). Atoms cache must be initialised. +fn mobBtMakeBinaryStr(env: ?*erts.ErlNifEnv, s: ?[*:0]const u8) erts.ERL_NIF_TERM { + const len: usize = if (s) |p| jni.strlen(p) else 0; + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + if (len > 0) { + if (s) |p| @memcpy(bin.data[0..len], p[0..len]); + } + return erts.enif_make_binary(env, &bin); +} + +/// Build a binary term from arbitrary bytes (PCM audio, raw HID reports, +/// SPP byte streams, etc). +fn mobBtMakeBinaryBytes(env: ?*erts.ErlNifEnv, bytes: ?[*]const u8, len: usize) erts.ERL_NIF_TERM { + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + if (len > 0) { + if (bytes) |p| @memcpy(bin.data[0..len], p[0..len]); + } + return erts.enif_make_binary(env, &bin); +} + +/// Build `%{address: <<...>>, name: <<...>>, bonded: bool}`. +fn mobBtMakeDeviceMap(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, name: ?[*:0]const u8, bonded: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_address, + mob_bt_atoms.k_name, + mob_bt_atoms.k_bonded, + }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + mobBtMakeBinaryStr(env, name), + if (bonded != 0) mob_bt_atoms.true_atom else mob_bt_atoms.false_atom, + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>}`. +fn mobBtMakeAddressOnly(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_address}; + const vals = [_]erts.ERL_NIF_TERM{mobBtMakeBinaryStr(env, address)}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>, name: <<...>>}`. +fn mobBtMakeAddressName(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, name: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ mob_bt_atoms.k_address, mob_bt_atoms.k_name }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + mobBtMakeBinaryStr(env, name), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{address: <<...>>, reason: :reason_atom}`. Reason defaults to +/// `:unknown` for null Kotlin strings — never explodes on missing data. +fn mobBtMakeAddressReason(env: ?*erts.ErlNifEnv, address: ?[*:0]const u8, reason: ?[*:0]const u8) erts.ERL_NIF_TERM { + const reason_atom = if (reason) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const keys = [_]erts.ERL_NIF_TERM{ mob_bt_atoms.k_address, mob_bt_atoms.k_reason }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, address), + reason_atom, + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{reason: :reason_atom}`. +fn mobBtMakeReasonOnly(env: ?*erts.ErlNifEnv, reason: ?[*:0]const u8) erts.ERL_NIF_TERM { + const reason_atom = if (reason) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_reason}; + const vals = [_]erts.ERL_NIF_TERM{reason_atom}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{cmd: <<...>>, cmd_type: int, args: <<...>>, address: <<...>>}` +/// for vendor AT events. cmd_type is the HFP AT command type code. +fn mobBtMakeVendorAtMap(env: ?*erts.ErlNifEnv, cmd: ?[*:0]const u8, cmd_type: c_int, args: ?[*:0]const u8, address: ?[*:0]const u8) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_cmd, + mob_bt_atoms.k_cmd_type, + mob_bt_atoms.k_args, + mob_bt_atoms.k_address, + }; + const vals = [_]erts.ERL_NIF_TERM{ + mobBtMakeBinaryStr(env, cmd), + erts.enif_make_int(env, cmd_type), + mobBtMakeBinaryStr(env, args), + mobBtMakeBinaryStr(env, address), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{size: int}` for write-completion events. +fn mobBtMakeSizeMap(env: ?*erts.ErlNifEnv, size: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{mob_bt_atoms.k_size}; + const vals = [_]erts.ERL_NIF_TERM{erts.enif_make_int(env, size)}; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +/// Build `%{type: int, code: int, value: int}` for HID input events. +/// Matches the Linux evdev struct input_event triple. +fn mobBtMakeInputMap(env: ?*erts.ErlNifEnv, ev_type: c_int, code: c_int, value: c_int) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + mob_bt_atoms.k_type, + mob_bt_atoms.k_code, + mob_bt_atoms.k_value, + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_int(env, ev_type), + erts.enif_make_int(env, code), + erts.enif_make_int(env, value), + }; + return erts.makeMap(env, &keys, &vals) orelse mob_bt_atoms.err; +} + +// ═════════════════════════════════════════════════════════════════════════ +// (4) Paired-list streaming accumulator +// ═════════════════════════════════════════════════════════════════════════ +// +// The Kotlin side streams paired devices one at a time (begin → 0..N +// entries → finish). We need a stable per-pid buffer to accumulate entries +// in, since Kotlin can interleave streams from concurrent callers. +// +// 16 buckets × 128 max entries each. Slot lookup/insert/remove holds the +// table mutex; enif_send happens AFTER the mutex is released so a slow +// consumer doesn't block other accumulators. + +const MOB_BT_PAIRED_BUCKETS: usize = 16; +const MOB_BT_PAIRED_MAX_ENTRIES: usize = 128; +const MOB_BT_ADDR_MAX: usize = 24; // "00:11:22:33:44:55" + null + slop +const MOB_BT_NAME_MAX: usize = 248; // BT spec max friendly name + null + +const MobBtPairedEntry = extern struct { + address: [MOB_BT_ADDR_MAX]u8, + name: [MOB_BT_NAME_MAX]u8, + bonded: c_int, +}; + +const MobBtPairedSlot = extern struct { + pid_long: jni.JLong, + in_use: c_int, + count: usize, + entries: [MOB_BT_PAIRED_MAX_ENTRIES]MobBtPairedEntry, +}; + +var mob_bt_paired_slots: [MOB_BT_PAIRED_BUCKETS]MobBtPairedSlot = blk: { + var buf: [MOB_BT_PAIRED_BUCKETS]MobBtPairedSlot = undefined; + for (&buf) |*s| s.* = std.mem.zeroes(MobBtPairedSlot); + break :blk buf; +}; +var mob_bt_paired_mutex: ?*erts.ErlNifMutex = null; + +/// Initialise the paired-list accumulator. Returns 0 on success, -1 on +/// mutex-create failure. Call from `nifLoad`. +pub fn mobBtPairedInit() c_int { + mob_bt_paired_mutex = erts.enif_mutex_create("mob_bt_paired_mutex") orelse return -1; + return 0; +} + +/// Find an in-use slot matching `pid_long`. Must hold the mutex. +fn mobBtPairedFindLocked(pid_long: jni.JLong) ?*MobBtPairedSlot { + for (&mob_bt_paired_slots) |*s| { + if (s.in_use != 0 and s.pid_long == pid_long) return s; + } + return null; +} + +/// Claim the first free slot for `pid_long`, OR — if `pid_long` already +/// has a slot — reset it for a new accumulation cycle. Must hold mutex. +fn mobBtPairedClaimLocked(pid_long: jni.JLong) ?*MobBtPairedSlot { + if (mobBtPairedFindLocked(pid_long)) |existing| { + existing.count = 0; + return existing; + } + for (&mob_bt_paired_slots) |*s| { + if (s.in_use == 0) { + s.in_use = 1; + s.pid_long = pid_long; + s.count = 0; + return s; + } + } + return null; // all slots in use — drop this accumulation +} + +/// Mark a slot free. Must hold mutex. +fn mobBtPairedReleaseLocked(slot: *MobBtPairedSlot) void { + slot.in_use = 0; + slot.pid_long = 0; + slot.count = 0; +} + +// ═════════════════════════════════════════════════════════════════════════ +// (5) BT envelope helper — 4-tuple {:bt, tag, session_or_nil, payload} +// ═════════════════════════════════════════════════════════════════════════ +// +// All BT deliveries share this shape. Session is either an integer +// (profile event) or `:nil` (discovery / pairing event). Channel atom is +// `:bt` for discovery/pairing/error, `:bt_hfp` / `:bt_spp` / `:bt_hid` +// for profile-scoped events. + +/// Return :nil or an integer for the session slot. +inline fn btSessionTerm(env: ?*erts.ErlNifEnv, session: c_int) erts.ERL_NIF_TERM { + return if (session < 0) mob_bt_atoms.nil_atom else erts.enif_make_int(env, session); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (6) Delivery functions — `mob_deliver_bt_*` exports +// ═════════════════════════════════════════════════════════════════════════ +// +// Called from beam_jni.c's Java_..._MobBridge_nativeDeliverBt* thunks when +// Kotlin emits BT events. Each builds the typed envelope tuple and posts +// it to `pid_long` (an ErlNifPid round-tripped through Kotlin as a +// jlong). +// +// 33 functions total. Same structural shape as the VendorUsb deliveries: +// alloc env, build msg, send, free env. No mutex contention except the +// paired-list trio (begin / entry / finish), which holds the accumulator +// mutex briefly. + +// ── Discovery (2-tuples — no payload) ────────────────────────────────── + +pub export fn mob_deliver_bt_discovery_started(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_started, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_discovery_finished(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_finished, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_discovery_cancelled(pid_long: jni.JLong) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovery_cancelled, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Discovery / pairing (3-tuples — no session) ──────────────────────── + +pub export fn mob_deliver_bt_discovered( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.discovered, + mobBtMakeDeviceMap(env, address, name, bonded), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_paired( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.paired, + mobBtMakeDeviceMap(env, address, name, bonded), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_pair_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.pair_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_unpaired(pid_long: jni.JLong, address: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.unpaired, + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_error(pid_long: jni.JLong, reason: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.err, + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Legacy JSON paired-devices (unused by current Elixir API but kept +// for compat with Kotlin templates that pre-date the streamed paired +// list accumulator). Just shoves the JSON binary in as the payload. + +pub export fn mob_deliver_bt_paired_devices(pid_long: jni.JLong, json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + erts.atom(env, "paired_devices_json"), + mob_bt_atoms.nil_atom, + mobBtMakeBinaryStr(env, json), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Paired-list streaming (begin / entry / finish) ───────────────────── + +pub export fn mob_deliver_bt_paired_list_begin(pid_long: jni.JLong) callconv(.c) void { + erts.enif_mutex_lock(mob_bt_paired_mutex); + _ = mobBtPairedClaimLocked(pid_long); + erts.enif_mutex_unlock(mob_bt_paired_mutex); +} + +pub export fn mob_deliver_bt_paired_list_entry( + pid_long: jni.JLong, + address: ?[*:0]const u8, + name: ?[*:0]const u8, + bonded: c_int, +) callconv(.c) void { + erts.enif_mutex_lock(mob_bt_paired_mutex); + defer erts.enif_mutex_unlock(mob_bt_paired_mutex); + + const slot = mobBtPairedFindLocked(pid_long) orelse return; + if (slot.count >= MOB_BT_PAIRED_MAX_ENTRIES) return; + + const entry = &slot.entries[slot.count]; + if (address) |a| { + const a_len = jni.strlen(a); + const a_copy = @min(a_len, entry.address.len - 1); + @memcpy(entry.address[0..a_copy], a[0..a_copy]); + entry.address[a_copy] = 0; + } else { + entry.address[0] = 0; + } + if (name) |n| { + const n_len = jni.strlen(n); + const n_copy = @min(n_len, entry.name.len - 1); + @memcpy(entry.name[0..n_copy], n[0..n_copy]); + entry.name[n_copy] = 0; + } else { + entry.name[0] = 0; + } + entry.bonded = if (bonded != 0) 1 else 0; + slot.count += 1; +} + +pub export fn mob_deliver_bt_paired_list_finish(pid_long: jni.JLong) callconv(.c) void { + // Snapshot under lock, then release before any term allocation. + var snapshot: MobBtPairedSlot = undefined; + + erts.enif_mutex_lock(mob_bt_paired_mutex); + const slot = mobBtPairedFindLocked(pid_long); + if (slot == null) { + erts.enif_mutex_unlock(mob_bt_paired_mutex); + return; + } + snapshot = slot.?.*; + mobBtPairedReleaseLocked(slot.?); + erts.enif_mutex_unlock(mob_bt_paired_mutex); + + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + var list = erts.enif_make_list(env, 0); + var i: usize = snapshot.count; + while (i > 0) { + i -= 1; + const entry = &snapshot.entries[i]; + const addr_ptr: [*:0]const u8 = @ptrCast(&entry.address); + const name_ptr: [*:0]const u8 = @ptrCast(&entry.name); + const dev = mobBtMakeDeviceMap(env, addr_ptr, name_ptr, entry.bonded); + list = erts.enif_make_list_cell(env, dev, list); + } + + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt, + mob_bt_atoms.paired_list, + list, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── HFP profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_hfp_connecting( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connecting, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, + name: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressName(env, address, name), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_vendor_subscribed(pid_long: jni.JLong, session: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.vendor_subscribed, + erts.enif_make_int(env, session), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_vendor_at( + pid_long: jni.JLong, + session: c_int, + cmd: ?[*:0]const u8, + cmd_type: c_int, + args: ?[*:0]const u8, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.vendor_at, + erts.enif_make_int(env, session), + mobBtMakeVendorAtMap(env, cmd, cmd_type, args, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_started( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_started, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_stopped(pid_long: jni.JLong, session: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_stopped, + erts.enif_make_int(env, session), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_sco_audio( + pid_long: jni.JLong, + session: c_int, + pcm: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.sco_audio, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, pcm, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hfp_error( + pid_long: jni.JLong, + session: c_int, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hfp, + mob_bt_atoms.err, + erts.enif_make_int(env, session), + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── SPP profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_spp_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, + name: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressName(env, address, name), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_data( + pid_long: jni.JLong, + session: c_int, + bytes: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.data, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, bytes, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_written(pid_long: jni.JLong, session: c_int, size: c_int) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.written, + erts.enif_make_int(env, session), + mobBtMakeSizeMap(env, size), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_spp_error( + pid_long: jni.JLong, + session: c_int, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_spp, + mob_bt_atoms.err, + erts.enif_make_int(env, session), + mobBtMakeReasonOnly(env, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── HID profile deliveries ───────────────────────────────────────────── + +pub export fn mob_deliver_bt_hid_connected( + pid_long: jni.JLong, + session: c_int, + address: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.connected, + erts.enif_make_int(env, session), + mobBtMakeAddressOnly(env, address), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_connect_failed( + pid_long: jni.JLong, + address: ?[*:0]const u8, + reason: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.connect_failed, + mobBtMakeAddressReason(env, address, reason), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_disconnected( + pid_long: jni.JLong, + session: c_int, + reason_atom: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const reason_term = if (reason_atom) |r| erts.enif_make_atom(env, r) else erts.atom(env, "unknown"); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.disconnected, + erts.enif_make_int(env, session), + reason_term, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_input( + pid_long: jni.JLong, + session: c_int, + ev_type: c_int, + code: c_int, + value: c_int, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.input, + erts.enif_make_int(env, session), + mobBtMakeInputMap(env, ev_type, code, value), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_bt_hid_raw_report( + pid_long: jni.JLong, + session: c_int, + bytes: ?[*]const u8, + len: usize, +) callconv(.c) void { + var pid = pidFromLong(pid_long); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + mob_bt_atoms.bt_hid, + mob_bt_atoms.raw_report, + erts.enif_make_int(env, session), + mobBtMakeBinaryBytes(env, bytes, len), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ═════════════════════════════════════════════════════════════════════════ +// (7) NIF wrappers — `nif_bt_*` +// ═════════════════════════════════════════════════════════════════════════ +// +// Mirror VendorUsb structurally: pull caller pid via enif_self, attach +// JNIEnv, dispatch via the cached jmethodID, return :ok. All responses +// come back asynchronously through the mob_deliver_bt_* hooks. +// +// `vendor_at_send` and `_send_audio` / `_spp_write` are the only ones +// with non-trivial argument marshalling (byte arrays for the audio / +// SPP writes, two strings for vendor AT). +// +// `unsupported` short-circuit mirrors VendorUsb's pattern: if MobBridge +// doesn't have the matching @JvmStatic (old mob_new template), emit a +// single `{:bt, :error, nil, %{reason: :unsupported}}` and return :ok. + +fn btUnsupported(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + const msg_env = erts.enif_alloc_env() orelse return erts.ok(env); + defer erts.enif_free_env(msg_env); + const unsupported = erts.atom(msg_env, "unsupported"); + const keys = [_]erts.ERL_NIF_TERM{erts.atom(msg_env, "reason")}; + const vals = [_]erts.ERL_NIF_TERM{unsupported}; + const map = erts.makeMap(msg_env, &keys, &vals) orelse unsupported; + const msg = erts.makeTuple(msg_env, .{ + erts.atom(msg_env, "bt"), + erts.atom(msg_env, "error"), + erts.atom(msg_env, "nil"), + map, + }); + _ = erts.enif_send(null, &pid, msg_env, msg); + return erts.ok(env); +} + +// ── No-arg discovery / paired-list NIFs ── + +export fn nif_bt_list_paired( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_list_paired == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_list_paired, pidToJlong(pid)); + return erts.ok(env); +} + +export fn nif_bt_start_discovery( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_start_discovery == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_start_discovery, pidToJlong(pid)); + return erts.ok(env); +} + +export fn nif_bt_cancel_discovery( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.bt_cancel_discovery == null) return btUnsupported(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.bt_cancel_discovery, pidToJlong(pid)); + return erts.ok(env); +} + +// ── JSON-arg NIFs (pair / unpair / hfp_connect / spp_connect / hid_connect) ── + +export fn nif_bt_pair( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_pair == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_pair, pid, json); +} + +export fn nif_bt_unpair( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_unpair == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_unpair, pid, json); +} + +export fn nif_bt_hfp_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_hfp_connect, pid, json); +} + +export fn nif_bt_spp_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_spp_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_spp_connect, pid, json); +} + +export fn nif_bt_hid_connect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hid_connect == null) return btUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.bt_hid_connect, pid, json); +} + +// ── Session-only NIFs ── + +export fn nif_bt_disconnect( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_disconnect == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_disconnect, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hfp_subscribe_vendor_at( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_subscribe_vendor_at == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jjson = jni.newStringUTF(jenv, json); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_subscribe_vendor_at, + pidToJlong(pid), + @as(jni.JInt, session), + jjson, + ); + if (jjson != null) jni.deleteLocalRef(jenv, jjson); + return erts.ok(env); +} + +export fn nif_bt_hfp_start_sco( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_start_sco == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_start_sco, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hfp_stop_sco( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_stop_sco == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_stop_sco, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_bt_hid_subscribe_raw( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hid_subscribe_raw == null) return btUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hid_subscribe_raw, + pidToJlong(pid), + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +// ── Two-string + session NIF (hfp_send_vendor_at/3) ── + +export fn nif_bt_hfp_send_vendor_at( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_send_vendor_at == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + const cmd_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const cmd = binToCString(cmd_bin) orelse return erts.atom(env, "error"); + defer freeCString(cmd); + + const args_bin = getBinOrIolist(env, argv[2]) orelse return erts.badarg(env); + const args = binToCString(args_bin) orelse return erts.atom(env, "error"); + defer freeCString(args); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jcmd = jni.newStringUTF(jenv, cmd); + const jargs = jni.newStringUTF(jenv, args); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_send_vendor_at, + pidToJlong(pid), + @as(jni.JInt, session), + jcmd, + jargs, + ); + if (jcmd != null) jni.deleteLocalRef(jenv, jcmd); + if (jargs != null) jni.deleteLocalRef(jenv, jargs); + return erts.ok(env); +} + +// ── Byte-array NIFs (hfp_send_audio/2, spp_write/2) — dirty IO ── + +export fn nif_bt_hfp_send_audio( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_hfp_send_audio == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_hfp_send_audio, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + +export fn nif_bt_spp_write( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.bt_spp_write == null) return btUnsupported(env); + + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.bt_spp_write, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + // ── nif_load: cache all method IDs at BEAM startup ─────────────────────── /// Required-method helper. Returns false if the method isn't on the @@ -3103,7 +4457,6 @@ inline fn cacheOptional(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const } fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) callconv(.c) c_int { - _ = env; _ = priv; _ = info; logi_nif("nif_load: entered, Bridge.cls={any}", .{Bridge.cls}); @@ -3194,6 +4547,34 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c cacheOptional(jenv, "vendor_usb_stop_reading", "(I)V", &Bridge.vendor_usb_stop_reading); cacheOptional(jenv, "vendor_usb_close", "(I)V", &Bridge.vendor_usb_close); + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + // Optional like VendorUsb — older mob_new templates without the matching + // Kotlin bt_* block will boot, with each bt_* NIF short-circuiting to + // {:bt, :error, nil, %{reason: :unsupported}}. + cacheOptional(jenv, "bt_list_paired", "(J)V", &Bridge.bt_list_paired); + cacheOptional(jenv, "bt_start_discovery", "(J)V", &Bridge.bt_start_discovery); + cacheOptional(jenv, "bt_cancel_discovery", "(J)V", &Bridge.bt_cancel_discovery); + cacheOptional(jenv, "bt_pair", "(JLjava/lang/String;)V", &Bridge.bt_pair); + cacheOptional(jenv, "bt_unpair", "(JLjava/lang/String;)V", &Bridge.bt_unpair); + cacheOptional(jenv, "bt_disconnect", "(JI)V", &Bridge.bt_disconnect); + cacheOptional(jenv, "bt_hfp_connect", "(JLjava/lang/String;)V", &Bridge.bt_hfp_connect); + cacheOptional(jenv, "bt_hfp_subscribe_vendor_at", "(JILjava/lang/String;)V", &Bridge.bt_hfp_subscribe_vendor_at); + cacheOptional(jenv, "bt_hfp_send_vendor_at", "(JILjava/lang/String;Ljava/lang/String;)V", &Bridge.bt_hfp_send_vendor_at); + cacheOptional(jenv, "bt_hfp_start_sco", "(JI)V", &Bridge.bt_hfp_start_sco); + cacheOptional(jenv, "bt_hfp_stop_sco", "(JI)V", &Bridge.bt_hfp_stop_sco); + cacheOptional(jenv, "bt_hfp_send_audio", "(JI[B)V", &Bridge.bt_hfp_send_audio); + cacheOptional(jenv, "bt_spp_connect", "(JLjava/lang/String;)V", &Bridge.bt_spp_connect); + cacheOptional(jenv, "bt_spp_write", "(JI[B)V", &Bridge.bt_spp_write); + cacheOptional(jenv, "bt_hid_connect", "(JLjava/lang/String;)V", &Bridge.bt_hid_connect); + cacheOptional(jenv, "bt_hid_subscribe_raw", "(JI)V", &Bridge.bt_hid_subscribe_raw); + + // BT atom cache + paired-list accumulator state. + mobBtAtomsInit(env); + if (mobBtPairedInit() != 0) { + loge_nif("nif_load: failed to create BT paired mutex", .{}); + return -1; + } + g_launch_notif_mutex = erts.enif_mutex_create("mob_launch_notif_mutex"); if (g_launch_notif_mutex == null) { loge_nif("nif_load: failed to create launch notif mutex", .{}); @@ -3311,6 +4692,23 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "vendor_usb_start_reading", .arity = 2, .fptr = nif_vendor_usb_start_reading, .flags = 0 }, .{ .name = "vendor_usb_stop_reading", .arity = 1, .fptr = nif_vendor_usb_stop_reading, .flags = 0 }, .{ .name = "vendor_usb_close", .arity = 1, .fptr = nif_vendor_usb_close, .flags = 0 }, + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + .{ .name = "bt_list_paired", .arity = 0, .fptr = nif_bt_list_paired, .flags = 0 }, + .{ .name = "bt_start_discovery", .arity = 0, .fptr = nif_bt_start_discovery, .flags = 0 }, + .{ .name = "bt_cancel_discovery", .arity = 0, .fptr = nif_bt_cancel_discovery, .flags = 0 }, + .{ .name = "bt_pair", .arity = 1, .fptr = nif_bt_pair, .flags = 0 }, + .{ .name = "bt_unpair", .arity = 1, .fptr = nif_bt_unpair, .flags = 0 }, + .{ .name = "bt_disconnect", .arity = 1, .fptr = nif_bt_disconnect, .flags = 0 }, + .{ .name = "bt_hfp_connect", .arity = 1, .fptr = nif_bt_hfp_connect, .flags = 0 }, + .{ .name = "bt_hfp_subscribe_vendor_at", .arity = 2, .fptr = nif_bt_hfp_subscribe_vendor_at, .flags = 0 }, + .{ .name = "bt_hfp_send_vendor_at", .arity = 3, .fptr = nif_bt_hfp_send_vendor_at, .flags = 0 }, + .{ .name = "bt_hfp_start_sco", .arity = 1, .fptr = nif_bt_hfp_start_sco, .flags = 0 }, + .{ .name = "bt_hfp_stop_sco", .arity = 1, .fptr = nif_bt_hfp_stop_sco, .flags = 0 }, + .{ .name = "bt_hfp_send_audio", .arity = 2, .fptr = nif_bt_hfp_send_audio, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "bt_spp_connect", .arity = 1, .fptr = nif_bt_spp_connect, .flags = 0 }, + .{ .name = "bt_spp_write", .arity = 2, .fptr = nif_bt_spp_write, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "bt_hid_connect", .arity = 1, .fptr = nif_bt_hid_connect, .flags = 0 }, + .{ .name = "bt_hid_subscribe_raw", .arity = 1, .fptr = nif_bt_hid_subscribe_raw, .flags = 0 }, }; var mob_nif_entry: erts.ErlNifEntry = .{ diff --git a/ios/mob_nif.m b/ios/mob_nif.m index c9a46c3d..422dc3e2 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5704,6 +5704,177 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF return enif_make_atom(env, "ok"); } +// ───────────────────────────────────────────────────────────────────────── +// Mob.Bt (Bluetooth Classic) — iOS unsupported stubs +// ───────────────────────────────────────────────────────────────────────── +// +// iOS exposes no public Bluetooth Classic API. (Bluetooth LE is available +// via CoreBluetooth, but Classic profiles like HFP/SPP/HID need MFi +// certification which Mob doesn't pursue.) All sixteen NIFs send +// {:bt, :error, nil, %{reason: :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_bt_unsupported(ErlNifPid pid) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM reason_key = enif_make_atom(e, "reason"); + ERL_NIF_TERM reason_val = enif_make_atom(e, "unsupported"); + ERL_NIF_TERM map; + enif_make_map_put(e, enif_make_new_map(e), reason_key, reason_val, &map); + ERL_NIF_TERM msg = enif_make_tuple4(e, + enif_make_atom(e, "bt"), + enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), + map); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +static ERL_NIF_TERM nif_bt_list_paired(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_start_discovery(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_cancel_discovery(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_pair(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_unpair(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_disconnect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_subscribe_vendor_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_send_vendor_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_start_sco(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_stop_sco(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hfp_send_audio(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_spp_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_spp_write(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hid_connect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_bt_hid_subscribe_raw(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_bt_unsupported(pid); + return enif_make_atom(env, "ok"); +} + + // Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a @@ -5817,6 +5988,23 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"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}, + // Bluetooth Classic (iOS = unsupported) + {"bt_list_paired", 0, nif_bt_list_paired, 0}, + {"bt_start_discovery", 0, nif_bt_start_discovery, 0}, + {"bt_cancel_discovery", 0, nif_bt_cancel_discovery, 0}, + {"bt_pair", 1, nif_bt_pair, 0}, + {"bt_unpair", 1, nif_bt_unpair, 0}, + {"bt_disconnect", 1, nif_bt_disconnect, 0}, + {"bt_hfp_connect", 1, nif_bt_hfp_connect, 0}, + {"bt_hfp_subscribe_vendor_at", 2, nif_bt_hfp_subscribe_vendor_at, 0}, + {"bt_hfp_send_vendor_at", 3, nif_bt_hfp_send_vendor_at, 0}, + {"bt_hfp_start_sco", 1, nif_bt_hfp_start_sco, 0}, + {"bt_hfp_stop_sco", 1, nif_bt_hfp_stop_sco, 0}, + {"bt_hfp_send_audio", 2, nif_bt_hfp_send_audio, 0}, + {"bt_spp_connect", 1, nif_bt_spp_connect, 0}, + {"bt_spp_write", 2, nif_bt_spp_write, 0}, + {"bt_hid_connect", 1, nif_bt_hid_connect, 0}, + {"bt_hid_subscribe_raw", 1, nif_bt_hid_subscribe_raw, 0}, // getaddrinfo can block on the resolver for seconds — dirty-IO so it // doesn't head-of-line-block the regular schedulers. See the impl // above for the iOS rationale. diff --git a/lib/mob/bt.ex b/lib/mob/bt.ex new file mode 100644 index 00000000..9ff853fa --- /dev/null +++ b/lib/mob/bt.ex @@ -0,0 +1,186 @@ +defmodule Mob.Bt do + @moduledoc """ + Bluetooth Classic (BR/EDR) — device-level discovery, pairing, and + cross-profile session management. + + Profile-specific operations live in submodules: + + * `Mob.Bt.Hfp` — Hands-Free Profile (audio + vendor AT commands). + Use this for headsets, PTT-equipped earpieces, etc. + * `Mob.Bt.Spp` — Serial Port Profile (RFCOMM byte streams). + Use this for legacy serial-over-Bluetooth devices (Arduino HC-05, + OBD-II readers, marine GPS, industrial sensors). + * `Mob.Bt.Hid` — Human Interface Device (input reports). + Use this for Bluetooth keyboards, mice, gamepads, finger PTTs. + + ## API style + + Same as the rest of Mob: callbacks return `socket` unchanged, results + arrive in `handle_info/2` as 4-tuples: + + {:bt, event_atom, session_id_or_nil, payload} + + Discovery / pairing events use `nil` for session_id; profile events + carry the session_id returned from the matching `connect/2`. + + ## Permissions + + Bluetooth requires runtime permissions on Android 12+ (API 31+): + + * `:bluetooth_scan` — for `start_discovery/1` + * `:bluetooth_connect` — for `pair/2`, `connect/*`, `disconnect/2` + + Request via `Mob.Permissions.request/2` before calling Mob.Bt functions. + + ## iOS + + Bluetooth Classic on iOS requires Apple's MFi (Made for iPhone) + certification — a paid, NDA-gated program. Mob.Bt is **Android-only**. + All functions return `{:error, :unsupported}` synchronously on iOS. + For iOS-equivalent custom-hardware connectivity, use `Mob.Ble`. + + ## Pairing flow + + Two pairing modes, auto-selected by whether `:pin` is given: + + # System UI flow — Android shows a system pairing dialog + socket = Mob.Bt.pair(socket, device) + + # Programmatic — PIN supplied via API, no UI + socket = Mob.Bt.pair(socket, device, pin: "0000") + + If the programmatic PIN fails or the device requires UI confirmation + (e.g. numeric comparison), Android falls back to the system UI + automatically. + + ## Disconnect + + One canonical disconnect for any profile session: + + Mob.Bt.disconnect(socket, session_id) + + The framework looks up which profile owns the session_id and routes + to the right profile-disconnect internally. Emits a profile-specific + event (`{:bt, :hfp_disconnected, ...}` etc). + """ + + @typedoc "An opaque session identifier for an active profile connection." + @type session_id :: pos_integer() + + @typedoc "A discovered or paired Bluetooth device." + @type device :: %{ + required(:address) => String.t(), + required(:name) => String.t(), + optional(:bond_state) => :none | :bonding | :bonded, + optional(:device_class) => non_neg_integer(), + optional(:uuids) => [String.t()] + } + + # ───────────────────────────────────────────────────────────── + # Public API + # ───────────────────────────────────────────────────────────── + + @doc """ + List currently paired (bonded) Bluetooth devices. + + Result arrives as `{:bt, :paired_devices, nil, [device]}`. + """ + @spec list_paired(socket :: term()) :: term() + def list_paired(socket) do + :mob_nif.bt_list_paired() + socket + end + + @doc """ + Begin Bluetooth Classic discovery. Discovered devices arrive as + individual `{:bt, :device_discovered, nil, device}` messages, terminated + by `{:bt, :discovery_finished, nil, nil}`. + + Discovery typically runs ~12 seconds on Android. + """ + @spec start_discovery(socket :: term()) :: term() + def start_discovery(socket) do + :mob_nif.bt_start_discovery() + socket + end + + @doc """ + Cancel an in-progress discovery. + """ + @spec cancel_discovery(socket :: term()) :: term() + def cancel_discovery(socket) do + :mob_nif.bt_cancel_discovery() + socket + end + + @doc """ + Pair (bond) with a Bluetooth device. + + Without `:pin`, Android shows the system pairing dialog (user enters + PIN). With `:pin`, attempts programmatic pairing using the supplied + PIN; falls back to system UI if the device demands user confirmation. + + Result arrives as one of: + + * `{:bt, :pair_succeeded, nil, device}` + * `{:bt, :pair_failed, nil, %{device: device, reason: atom()}}` + """ + @spec pair(socket :: term(), device(), keyword()) :: term() + def pair(socket, device, opts \\ []) do + pin = Keyword.get(opts, :pin) + json = encode_pair(device, pin) + :mob_nif.bt_pair(json) + socket + end + + @doc """ + Remove an existing pairing (bond). + + Result: `{:bt, :unpaired, nil, device}`. + """ + @spec unpair(socket :: term(), device()) :: term() + def unpair(socket, device) do + json = encode_device(device) + :mob_nif.bt_unpair(json) + socket + end + + @doc """ + Disconnect a profile session by `session_id`. + + Works for any profile (`Mob.Bt.Hfp`, `Mob.Bt.Spp`, `Mob.Bt.Hid`) — the + framework dispatches internally based on which profile owns the session. + + Emits a profile-specific disconnect event: + + * `{:bt, :hfp_disconnected, session_id, reason}` + * `{:bt, :spp_disconnected, session_id, reason}` + * `{:bt, :hid_disconnected, session_id, reason}` + """ + @spec disconnect(socket :: term(), session_id()) :: term() + def disconnect(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_disconnect(session_id) + socket + end + + # Internal — JSON helpers (nil-safe per the VendorUsb playbook) + # ───────────────────────────────────────────────────────────── + + defp encode_pair(device, nil), do: encode_device(device) + + defp encode_pair(device, pin) when is_binary(pin) do + device |> Map.put(:pin, pin) |> encode_device() + end + + defp encode_device(device) do + device + |> Map.new() + |> drop_nil_values() + |> :json.encode() + |> IO.iodata_to_binary() + end + + defp drop_nil_values(map) do + :maps.filter(fn _k, v -> v != nil end, map) + end +end diff --git a/lib/mob/bt/hfp.ex b/lib/mob/bt/hfp.ex new file mode 100644 index 00000000..8e859a78 --- /dev/null +++ b/lib/mob/bt/hfp.ex @@ -0,0 +1,179 @@ +defmodule Mob.Bt.Hfp do + @moduledoc """ + Bluetooth Classic Hands-Free Profile (HFP) — audio + vendor AT commands. + + Use this for headsets, PTT-equipped earpieces (Hytera EHW02, etc), and + any device that exposes an HFP control link plus an SCO audio link. + + See `Mob.Bt` for pairing, discovery, and disconnect — those are + device-level concerns. Profile-specific operations live here. + + ## Typical flow + + # 1. Pair (only needed once per device — Mob.Bt.pair/2) + socket = Mob.Bt.pair(socket, device) + # {:bt, :pair_succeeded, nil, device} + + # 2. Connect HFP profile + socket = Mob.Bt.Hfp.connect(socket, device) + # {:bt, :hfp_connected, session_id, device} + + # 3. (Optional) subscribe to vendor AT commands the headset emits. + # Hytera EHW02 fires +CTXD on PTT press, +CUTXC on release. + socket = Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id) + # {:bt, :vendor_at, session_id, %{cmd: "+CTXD", args: ""}} + + # 4. (Optional) bring up the SCO audio link. + socket = Mob.Bt.Hfp.start_sco(socket, session_id) + # {:bt, :sco_started, session_id, %{sample_rate: 8000, ...}} + # then audio chunks stream as: + # {:bt, :sco_audio_in, session_id, pcm_bytes} + + # 5. Send PCM audio out to the headset earpiece: + Mob.Bt.Hfp.send_audio(socket, session_id, pcm_bytes) + + # 6. Disconnect (one canonical path — Mob.Bt.disconnect/2) + Mob.Bt.disconnect(socket, session_id) + + ## Vendor AT commands + + HFP defines a small core AT vocabulary (call control, volume, ring). + Headset vendors extend with their own `+`-prefixed commands. Subscribing + via `subscribe_vendor_at/2` delivers any *unrecognized* AT command from + the headset as `{:bt, :vendor_at, session_id, %{cmd, args}}` for your + app to interpret. + + Sending a vendor AT command to the headset is `send_vendor_at/4`. + Standard responses (`OK`, `ERROR`) are emitted by Android automatically — + use the `response` argument to override only when the AT spec demands + custom payload. + + ## SCO audio + + SCO (Synchronous Connection-Oriented) is the real-time bidirectional + voice channel HFP uses for call audio. `start_sco/2` opens it; PCM + bytes flow both ways until `stop_sco/2` or disconnect. + + Format is 8 kHz / 16-bit / mono PCM by default; modern devices may + negotiate up to 16 kHz wideband (mSBC). The `:sco_started` event + reports the negotiated parameters. + """ + + alias Mob.Bt + + @doc """ + Open an HFP profile connection to `device`. The device must already + be paired (`Mob.Bt.pair/2`). + + Result: `{:bt, :hfp_connected, session_id, device}` on success, + `{:bt, :hfp_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device()) :: term() + def connect(socket, device) do + json = encode_device(device) + :mob_nif.bt_hfp_connect(json) + socket + end + + @doc """ + Subscribe to vendor-specific AT commands emitted by the headset on the + given HFP session. + + The caller specifies which BT SIG company IDs to listen for via the + `:company_ids` option. Android's ACTION_VENDOR_SPECIFIC_HEADSET_EVENT + broadcasts are only delivered for explicitly-registered IDs, so a + default empty list means no events will be received. + + Common values: + + * `313` — Hytera (PTT commercial radios) + * `76` — Apple (AirPods custom events) + * `10` — Qualcomm + * `1117` — Plantronics / Poly + + Standard (non-vendor) AT commands are handled by Android's HFP stack + and never surface here. + + Stream events: `{:bt, :vendor_at, session_id, %{cmd: String.t(), cmd_type: integer(), args: String.t(), address: String.t()}}`. + + ## Example + + Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id, company_ids: [313]) + """ + @spec subscribe_vendor_at(socket :: term(), Bt.session_id(), keyword()) :: term() + def subscribe_vendor_at(socket, session_id, opts \\ []) + when is_integer(session_id) and is_list(opts) do + company_ids = Keyword.get(opts, :company_ids, []) + json = Jason.encode!(%{company_ids: company_ids}) + :mob_nif.bt_hfp_subscribe_vendor_at(session_id, json) + socket + end + + @doc """ + Send a vendor AT command to the headset. Useful for headset-specific + feature toggles or query/response protocols. + + Mob.Bt.Hfp.send_vendor_at(socket, session, "+XAPL", "0505,2") + """ + @spec send_vendor_at(socket :: term(), Bt.session_id(), String.t(), String.t()) :: term() + def send_vendor_at(socket, session_id, cmd, args \\ "") + when is_integer(session_id) and is_binary(cmd) and is_binary(args) do + :mob_nif.bt_hfp_send_vendor_at(session_id, cmd, args) + socket + end + + @doc """ + Open the SCO audio link for this HFP session. + + Emits `{:bt, :sco_started, session_id, %{sample_rate: integer, encoding: atom, channels: integer}}` + when the link is up. Mic audio then streams as + `{:bt, :sco_audio_in, session_id, pcm_bytes}`. + + On failure: `{:bt, :sco_failed, session_id, reason}`. + """ + @spec start_sco(socket :: term(), Bt.session_id()) :: term() + def start_sco(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hfp_start_sco(session_id) + socket + end + + @doc """ + Close the SCO audio link without disconnecting the HFP session. + + Emits `{:bt, :sco_stopped, session_id, nil}`. + """ + @spec stop_sco(socket :: term(), Bt.session_id()) :: term() + def stop_sco(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hfp_stop_sco(session_id) + socket + end + + @doc """ + Send PCM audio bytes out the SCO link to the headset earpiece. + + Bytes are linear PCM matching the format reported in `:sco_started` + (typically 8 kHz / 16-bit / mono signed little-endian). + + Returns the socket. This is fire-and-forget; no completion event. + """ + @spec send_audio(socket :: term(), Bt.session_id(), binary()) :: term() + def send_audio(socket, session_id, pcm_bytes) + when is_integer(session_id) and is_binary(pcm_bytes) do + :mob_nif.bt_hfp_send_audio(session_id, pcm_bytes) + socket + end + + @doc false + # ───────────────────────────────────────────────────────────── + # JSON helpers + # ───────────────────────────────────────────────────────────── + + defp encode_device(device) do + device + |> Map.new() + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + end +end diff --git a/lib/mob/bt/hid.ex b/lib/mob/bt/hid.ex new file mode 100644 index 00000000..31516676 --- /dev/null +++ b/lib/mob/bt/hid.ex @@ -0,0 +1,99 @@ +defmodule Mob.Bt.Hid do + @moduledoc """ + Bluetooth Classic Human Interface Device (HID) — input listener. + + Use this for Bluetooth keyboards, mice, gamepads, finger PTTs, scanners, + presenter remotes, and any device that emits HID input reports + (button/key/axis events) over Bluetooth. + + See `Mob.Bt` for pairing, discovery, and disconnect. + + ## Scope + + This module is **read-only** by design. HID hosts (phones) almost never + send output reports to peripherals — that's a force-feedback / + rumble-pack edge case. If your hardware genuinely needs output reports, + open an issue. + + ## Typical flow + + # 1. Pair (Mob.Bt.pair/2) + + # 2. Connect HID profile. + socket = Mob.Bt.Hid.connect(socket, device) + # {:bt, :hid_connected, session_id, device} + + # 3. Input reports stream: + # {:bt, :hid_input, session_id, + # %{usage_page: 0x07, usage: 0x29, value: 1}} + # (HID Keyboard/Keypad, key 0x29 = Escape, pressed) + + # 4. Disconnect (Mob.Bt.disconnect/2) + + ## Input report shape + + Reports are decoded by the Android HID stack into usage-page + + usage + value triples per the HID Usage Tables spec. Common pages: + + * `0x01` — Generic Desktop (mouse/joystick X/Y, wheel, etc.) + * `0x07` — Keyboard/Keypad + * `0x09` — Button (gamepad face buttons) + * `0x0C` — Consumer (volume, play, mute, custom) + * `0xFF00`–`0xFFFF` — Vendor-defined + + Multi-axis events arrive as separate messages, one per axis. Synthesize + combined input on the receive side if needed. + + ## Receiving raw reports + + If the device's HID descriptor is non-standard or the high-level + `:hid_input` shape isn't sufficient, subscribe to raw reports with + `subscribe_raw/2` and parse the bytes yourself. + + Stream: `{:bt, :hid_raw_report, session_id, %{report_id: integer, bytes: binary}}`. + """ + + alias Mob.Bt + + @doc """ + Open an HID profile connection to `device`. The device must already be + paired. + + Result: `{:bt, :hid_connected, session_id, device}` on success, + `{:bt, :hid_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device()) :: term() + def connect(socket, device) do + json = encode_device(device) + :mob_nif.bt_hid_connect(json) + socket + end + + @doc """ + Subscribe to raw HID input reports (bypasses Android's parser). + + Use only when the device's HID descriptor is non-standard or the + high-level `:hid_input` events miss data you need. + + Stream: `{:bt, :hid_raw_report, session_id, %{report_id, bytes}}`. + """ + @spec subscribe_raw(socket :: term(), Bt.session_id()) :: term() + def subscribe_raw(socket, session_id) when is_integer(session_id) do + :mob_nif.bt_hid_subscribe_raw(session_id) + socket + end + + @doc false + # ───────────────────────────────────────────────────────────── + # JSON helpers + # ───────────────────────────────────────────────────────────── + + defp encode_device(device) do + device + |> Map.new() + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + end +end diff --git a/lib/mob/bt/spp.ex b/lib/mob/bt/spp.ex new file mode 100644 index 00000000..a9d0aa9e --- /dev/null +++ b/lib/mob/bt/spp.ex @@ -0,0 +1,95 @@ +defmodule Mob.Bt.Spp do + @moduledoc """ + Bluetooth Classic Serial Port Profile (SPP) — RFCOMM byte streams. + + Use this for legacy serial-over-Bluetooth devices: Arduino HC-05/HC-06 + modules, OBD-II ELM327 readers, marine GPS pucks, industrial sensors, + legacy barcode scanners, etc. Anything that exposes itself as a + bidirectional byte pipe over a custom RFCOMM channel UUID. + + See `Mob.Bt` for pairing, discovery, and disconnect. + + ## Typical flow + + # 1. Pair (Mob.Bt.pair/2) + + # 2. Connect SPP, supplying the RFCOMM service UUID. + # The well-known SPP UUID is "00001101-0000-1000-8000-00805F9B34FB". + socket = Mob.Bt.Spp.connect(socket, device, + uuid: "00001101-0000-1000-8000-00805F9B34FB") + # {:bt, :spp_connected, session_id, device} + + # 3. Receive bytes: + # {:bt, :spp_data, session_id, bytes} + + # 4. Send bytes: + Mob.Bt.Spp.write(socket, session_id, "ATZ\\r\\n") + + # 5. Disconnect (Mob.Bt.disconnect/2) + + ## UUIDs + + Most SPP devices advertise the standard SPP UUID + `00001101-0000-1000-8000-00805F9B34FB`. Some manufacturers use custom + UUIDs to scope to a specific protocol on the same physical device. + Pass via the `:uuid` opt; if omitted, the standard SPP UUID is used. + + ## Insecure RFCOMM + + By default the connection uses the secure RFCOMM channel (encrypted, + requires bond). Some legacy devices (especially HC-06 clones) only + accept insecure RFCOMM. Pass `secure: false` to fall back. + """ + + alias Mob.Bt + + @standard_spp_uuid "00001101-0000-1000-8000-00805F9B34FB" + + @doc """ + Open an SPP (RFCOMM) connection to `device`. + + ## Options + + * `:uuid` — RFCOMM service UUID (default: `"#{@standard_spp_uuid}"`) + * `:secure` — `true` (default, encrypted) or `false` (legacy insecure) + + Result: `{:bt, :spp_connected, session_id, device}` on success, + `{:bt, :spp_connect_failed, nil, %{device: device, reason: atom()}}` + on failure. + """ + @spec connect(socket :: term(), Bt.device(), keyword()) :: term() + def connect(socket, device, opts \\ []) do + uuid = Keyword.get(opts, :uuid, @standard_spp_uuid) + secure = Keyword.get(opts, :secure, true) + + json = + device + |> Map.new() + |> Map.put(:uuid, uuid) + |> Map.put(:secure, secure) + |> Map.reject(fn {_k, v} -> is_nil(v) end) + |> :json.encode() + |> IO.iodata_to_binary() + + :mob_nif.bt_spp_connect(json) + socket + end + + @doc """ + Write a byte payload to the SPP session. + + Returns the socket. Fire-and-forget: bytes are queued in Kotlin's + output stream and flushed asynchronously. No completion event. + + Errors during write are surfaced as + `{:bt, :spp_disconnected, session_id, reason}` (Kotlin closes the + socket on write failure). + """ + @spec write(socket :: term(), Bt.session_id(), binary()) :: term() + def write(socket, session_id, bytes) + when is_integer(session_id) and is_binary(bytes) do + :mob_nif.bt_spp_write(session_id, bytes) + socket + end + +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 722932d2..b1644ede 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -104,6 +104,23 @@ vendor_usb_start_reading/2, vendor_usb_stop_reading/1, vendor_usb_close/1, + %% Bluetooth Classic (Android; iOS returns :unsupported) + bt_list_paired/0, + bt_start_discovery/0, + bt_cancel_discovery/0, + bt_pair/1, + bt_unpair/1, + bt_disconnect/1, + bt_hfp_connect/1, + bt_hfp_subscribe_vendor_at/2, + bt_hfp_send_vendor_at/3, + bt_hfp_start_sco/1, + bt_hfp_stop_sco/1, + bt_hfp_send_audio/2, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, %% DNS — see Mob.DNS and guides/dns_on_ios.md resolve_ipv4/1 ]). @@ -196,6 +213,23 @@ vendor_usb_start_reading/2, vendor_usb_stop_reading/1, vendor_usb_close/1, + %% Bluetooth Classic + bt_list_paired/0, + bt_start_discovery/0, + bt_cancel_discovery/0, + bt_pair/1, + bt_unpair/1, + bt_disconnect/1, + bt_hfp_connect/1, + bt_hfp_subscribe_vendor_at/2, + bt_hfp_send_vendor_at/3, + bt_hfp_start_sco/1, + bt_hfp_stop_sco/1, + bt_hfp_send_audio/2, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, %% DNS — in-process getaddrinfo so iOS apps bypass BEAM's %% broken inet_gethost path. See `Mob.DNS` for the Elixir %% wrapper and `guides/dns_on_ios.md` for the why. @@ -289,4 +323,21 @@ vendor_usb_bulk_write(_Session, _Bytes, _TimeoutMs) -> erlang:nif_error(not_load 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). +%% Bluetooth Classic +bt_list_paired() -> erlang:nif_error(not_loaded). +bt_start_discovery() -> erlang:nif_error(not_loaded). +bt_cancel_discovery() -> erlang:nif_error(not_loaded). +bt_pair(_DeviceAndPinJson) -> erlang:nif_error(not_loaded). +bt_unpair(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_disconnect(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hfp_subscribe_vendor_at(_Session, _CompanyIdsJson) -> erlang:nif_error(not_loaded). +bt_hfp_send_vendor_at(_Session, _Cmd, _Args) -> erlang:nif_error(not_loaded). +bt_hfp_start_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_stop_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_send_audio(_Session, _Pcm) -> erlang:nif_error(not_loaded). +bt_spp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_spp_write(_Session, _Bytes) -> erlang:nif_error(not_loaded). +bt_hid_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hid_subscribe_raw(_Session) -> erlang:nif_error(not_loaded). resolve_ipv4(_Host) -> erlang:nif_error(not_loaded).