diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index fbb371e..75ad7f6 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -193,6 +193,8 @@ pub inline fn enif_make_uint64(env: ?*ErlNifEnv, i: u64) ERL_NIF_TERM { // enif_make_copy. pub extern fn enif_alloc_env() ?*ErlNifEnv; pub extern fn enif_free_env(env: ?*ErlNifEnv) void; +pub extern fn enif_alloc(size: usize) ?*anyopaque; +pub extern fn enif_free(ptr: ?*anyopaque) void; pub extern fn enif_make_copy(dst: ?*ErlNifEnv, src_term: ERL_NIF_TERM) ERL_NIF_TERM; pub extern fn enif_send( caller_env: ?*ErlNifEnv, @@ -209,6 +211,10 @@ pub extern fn enif_whereis_pid(env: ?*ErlNifEnv, name: ERL_NIF_TERM, pid: *ErlNi // Tuple inspectors (iter 3c). pub extern fn enif_get_tuple(env: ?*ErlNifEnv, tpl: ERL_NIF_TERM, arity: *c_int, array: *[*]const ERL_NIF_TERM) c_int; +// List inspectors. +pub extern fn enif_get_list_length(env: ?*ErlNifEnv, term: ERL_NIF_TERM, len: *c_uint) c_int; +pub extern fn enif_get_list_cell(env: ?*ErlNifEnv, term: ERL_NIF_TERM, head: *ERL_NIF_TERM, tail: *ERL_NIF_TERM) c_int; + // Mutex (iter 3c). enif_mutex_create allocates; destroy + try-lock omitted // — Mob only uses simple lock/unlock pairs and the mutexes live for the // lifetime of the BEAM process (no destroy needed). diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index bd48c70..d1e12a7 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -241,6 +241,9 @@ pub export var Bridge: BridgeMethods = .{}; extern var g_jvm: ?*jni.JavaVM; extern var g_activity: jni.JObject; +// mob_iap plugin init — optional; iap.c short-circuits when plugin absent. +extern fn mob_iap_init(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void; + // ── get_jenv: attach the current thread if needed ──────────────────────── // Returns the env pointer; *attached is set to 1 iff this call had to // attach (caller must DetachCurrentThread when done). Match the C @@ -4736,6 +4739,11 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c return -1; } + // mob_iap plugin — optional; iap.c short-circuits when class absent. + if (g_activity != null) { + mob_iap_init(jenv, g_activity); + } + 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", .{}); @@ -4760,6 +4768,128 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c return 0; } +// ── In-App Purchase NIF stubs (mob_iap plugin) ───────────────────────── +// Thin wrappers that extract the BEAM pid and product IDs, then delegate +// to the JNI bridge in iap.c. The actual StoreKit 2 / Play Billing work +// happens on the JVM/ObjC side. + +// NOTE: iap.c JNI callbacks receive ErlNifPid* via jlong. They MUST +// call free() on that pointer after enif_send completes. +extern fn mob_iap_fetch_products(pid: *erts.ErlNifPid, ids: [*:null]const ?[*:0]const u8, count: c_int) void; +extern fn mob_iap_purchase(pid: *erts.ErlNifPid, product_id: [*:0]const u8) void; +extern fn mob_iap_restore(pid: *erts.ErlNifPid) void; +extern fn mob_iap_current_entitlements(pid: *erts.ErlNifPid) void; +extern fn mob_iap_manage_subscriptions() void; + +fn nif_iap_fetch_products( + env: ?*erts.ErlNifEnv, + _: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + if (erts.enif_self(env, &pid) == null) { + return erts.badarg(env); + } + + var list_len: c_uint = 0; + if (erts.enif_get_list_length(env, argv[0], &list_len) == 0) { + return erts.badarg(env); + } + + const max = @min(list_len, 128); + var ids: [128]?[*:0]const u8 = @splat(null); + var head: erts.ERL_NIF_TERM = undefined; + var tail: erts.ERL_NIF_TERM = argv[0]; + var buf: [4096]u8 = undefined; + + for (0..max) |i| { + if (erts.enif_get_list_cell(env, tail, &head, &tail) == 0) { + for (0..i) |j| erts.enif_free(@ptrCast(@constCast(ids[j].?))); + return erts.badarg(env); + } + if (!fillBufferFromTerm(env, head, &buf)) { + for (0..i) |j| erts.enif_free(@ptrCast(@constCast(ids[j].?))); + return erts.badarg(env); + } + const cstr: [*:0]u8 = @ptrCast(&buf); + const len = std.mem.len(cstr) + 1; + const str = erts.enif_alloc(len); + if (str == null) { + for (0..i) |j| erts.enif_free(@ptrCast(@constCast(ids[j].?))); + return erts.badarg(env); + } + @memcpy(@as([*]u8, @ptrCast(str.?))[0..len], buf[0..len]); + ids[i] = @ptrCast(str); + } + + const pid_ptr = erts.enif_alloc(@sizeOf(erts.ErlNifPid)) orelse return erts.badarg(env); + @as(*erts.ErlNifPid, @ptrCast(@alignCast(pid_ptr))).* = pid; + mob_iap_fetch_products(@ptrCast(@alignCast(pid_ptr)), @ptrCast(&ids[0]), @intCast(max)); + return erts.atom(env, "ok"); +} + +fn nif_iap_purchase( + env: ?*erts.ErlNifEnv, + _: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + if (erts.enif_self(env, &pid) == null) { + return erts.badarg(env); + } + + var buf: [4096]u8 = @splat(0); + if (!fillBufferFromTerm(env, argv[0], &buf)) { + return erts.badarg(env); + } + + const pid_ptr = erts.enif_alloc(@sizeOf(erts.ErlNifPid)) orelse return erts.badarg(env); + @as(*erts.ErlNifPid, @ptrCast(@alignCast(pid_ptr))).* = pid; + mob_iap_purchase(@ptrCast(@alignCast(pid_ptr)), @ptrCast(&buf)); + return erts.atom(env, "ok"); +} + +fn nif_iap_restore( + env: ?*erts.ErlNifEnv, + _: c_int, + _: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + if (erts.enif_self(env, &pid) == null) { + return erts.badarg(env); + } + + const pid_ptr = erts.enif_alloc(@sizeOf(erts.ErlNifPid)) orelse return erts.badarg(env); + @as(*erts.ErlNifPid, @ptrCast(@alignCast(pid_ptr))).* = pid; + mob_iap_restore(@ptrCast(@alignCast(pid_ptr))); + return erts.atom(env, "ok"); +} + +fn nif_iap_current_entitlements( + env: ?*erts.ErlNifEnv, + _: c_int, + _: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + if (erts.enif_self(env, &pid) == null) { + return erts.badarg(env); + } + + const pid_ptr = erts.enif_alloc(@sizeOf(erts.ErlNifPid)) orelse return erts.badarg(env); + @as(*erts.ErlNifPid, @ptrCast(@alignCast(pid_ptr))).* = pid; + mob_iap_current_entitlements(@ptrCast(@alignCast(pid_ptr))); + return erts.atom(env, "ok"); +} + +fn nif_iap_manage_subscriptions( + env: ?*erts.ErlNifEnv, + _: c_int, + _: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + mob_iap_manage_subscriptions(); + return erts.atom(env, "ok"); +} + // ── NIF table + ERL_NIF_INIT entry point ───────────────────────────────── // Replaces the static `ErlNifFunc nif_funcs[]` + `ERL_NIF_INIT` macro // that used to live at the bottom of mob_nif.c. The entry point is the @@ -4874,6 +5004,12 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .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 }, + // ── In-App Purchase (mob_iap plugin) ────────────────────────────────────── + .{ .name = "iap_fetch_products", .arity = 1, .fptr = nif_iap_fetch_products, .flags = 0 }, + .{ .name = "iap_purchase", .arity = 1, .fptr = nif_iap_purchase, .flags = 0 }, + .{ .name = "iap_restore", .arity = 0, .fptr = nif_iap_restore, .flags = 0 }, + .{ .name = "iap_current_entitlements", .arity = 0, .fptr = nif_iap_current_entitlements, .flags = 0 }, + .{ .name = "iap_manage_subscriptions", .arity = 0, .fptr = nif_iap_manage_subscriptions, .flags = 0 }, }; var mob_nif_entry: erts.ErlNifEntry = .{ diff --git a/ios/mob_nif.m b/ios/mob_nif.m index d4f1bb5..af6ab25 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2039,6 +2039,195 @@ static void mob_send3(const ErlNifPid *pid, const char *a1, const char *a2, cons enif_free_env(e); } +// ════════════════════════════════════════════════════════════════════════════ +// IAP bridge helpers — called from MobIapBridge.swift via @_silgen_name +// +// pid_bytes lifetime contract: every mob_iap_send_* below frees pid_bytes +// before returning. The Swift bridge MUST allocate one ErlNifPid copy per +// pending operation and call exactly one mob_iap_send_* with it. Calling +// two send helpers with the same pid_bytes is a use-after-free; storing +// pid_bytes after a send is a use-after-free. If a single user action +// could produce multiple events (e.g. :purchase_pending then :purchased), +// the bridge must store the BEAM pid value itself (not the heap copy) +// and allocate a fresh ErlNifPid for each enif_send. +// +// Symbols are intentionally non-static: Swift's @_silgen_name needs to +// link them across the bridging-header-less Swift/ObjC NIF boundary. +// ════════════════════════════════════════════════════════════════════════════ + +// Send {:iap, atom} to the BEAM process identified by serialized ErlNifPid. +// pid_bytes points to a serialized ErlNifPid copied by the NIF caller. +void mob_iap_send2(const void *pid_bytes, const char *tag, const char *atom) { + ErlNifPid pid; + memcpy(&pid, pid_bytes, sizeof(ErlNifPid)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, tag), enif_make_atom(e, atom)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + free((void *)pid_bytes); +} + +// Send {:iap, tag, atom} to the BEAM. +void mob_iap_send3(const void *pid_bytes, const char *tag, const char *a1, const char *a2) { + ErlNifPid pid; + memcpy(&pid, pid_bytes, sizeof(ErlNifPid)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, tag), enif_make_atom(e, a1), enif_make_atom(e, a2)); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + free((void *)pid_bytes); +} + +// Send {:iap, :products, binary_json} — JSON list of product maps. +void mob_iap_send_products(const void *pid_bytes, const char *json) { + ErlNifPid pid; + memcpy(&pid, pid_bytes, sizeof(ErlNifPid)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM json_bin; + size_t len = strlen(json); + unsigned char *buf = enif_make_new_binary(e, len, &json_bin); + memcpy(buf, json, len); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "iap"), enif_make_atom(e, "products"), json_bin); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + free((void *)pid_bytes); +} + +// Send {:iap, tag, binary_json} — a single transaction as JSON map. +void mob_iap_send_transaction(const void *pid_bytes, const char *tag, const char *json) { + ErlNifPid pid; + memcpy(&pid, pid_bytes, sizeof(ErlNifPid)); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM json_bin; + size_t len = strlen(json); + unsigned char *buf = enif_make_new_binary(e, len, &json_bin); + memcpy(buf, json, len); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "iap"), enif_make_atom(e, tag), json_bin); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + free((void *)pid_bytes); +} + +// Send {:iap, tag, binary_json} — a JSON array of transactions. +void mob_iap_send_transactions(const void *pid_bytes, const char *tag, const char *json) { + mob_iap_send_transaction(pid_bytes, tag, json); +} + +// ════════════════════════════════════════════════════════════════════════════ +// IAP NIF function implementations +// ════════════════════════════════════════════════════════════════════════════ + +// Extract the product IDs list from a term — expects a list of binaries. +// On failure, frees any ids already allocated and returns 0. +static size_t iap_extract_product_ids(ErlNifEnv *env, ERL_NIF_TERM list, char **ids, + size_t max_ids) { + unsigned int list_len; + if (!enif_get_list_length(env, list, &list_len)) + return 0; + if (list_len > max_ids) + return 0; + + ERL_NIF_TERM head, tail = list; + for (unsigned int i = 0; i < list_len; i++) { + if (!enif_get_list_cell(env, tail, &head, &tail)) { + for (unsigned int j = 0; j < i; j++) + free(ids[j]); + return 0; + } + ErlNifBinary bin; + if (!enif_inspect_binary(env, head, &bin) && + !enif_inspect_iolist_as_binary(env, head, &bin)) { + for (unsigned int j = 0; j < i; j++) + free(ids[j]); + return 0; + } + ids[i] = strndup((const char *)bin.data, bin.size); + } + return list_len; +} + +static ERL_NIF_TERM nif_iap_fetch_products(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (argc != 1) + return enif_make_badarg(env); + + char *ids[128]; + memset(ids, 0, sizeof(ids)); + size_t count = iap_extract_product_ids(env, argv[0], ids, 128); + // iap_extract_product_ids returns 0 on failure AND frees any partial + // allocations itself. An empty input list is also 0 — which is a + // valid edge case but useless work, so we reject both with badarg. + if (count == 0) + return enif_make_badarg(env); + + ErlNifPid *pid_copy = malloc(sizeof(ErlNifPid)); + enif_self(env, pid_copy); + + // Build NSArray and pass to MobIapBridge + NSMutableArray *productIds = [NSMutableArray arrayWithCapacity:count]; + for (size_t i = 0; i < count; i++) { + [productIds addObject:[NSString stringWithUTF8String:ids[i]]]; + free(ids[i]); + } + + [MobIapBridge fetchProducts:productIds pidBytes:pid_copy]; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_iap_purchase(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (argc != 1) + return enif_make_badarg(env); + + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + + NSString *productId = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + + ErlNifPid *pid_copy = malloc(sizeof(ErlNifPid)); + enif_self(env, pid_copy); + + [MobIapBridge purchase:productId pidBytes:pid_copy]; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_iap_restore(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + if (argc != 0) + return enif_make_badarg(env); + + ErlNifPid *pid_copy = malloc(sizeof(ErlNifPid)); + enif_self(env, pid_copy); + + [MobIapBridge restorePurchases:pid_copy]; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_iap_current_entitlements(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + if (argc != 0) + return enif_make_badarg(env); + + ErlNifPid *pid_copy = malloc(sizeof(ErlNifPid)); + enif_self(env, pid_copy); + + [MobIapBridge currentEntitlements:pid_copy]; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_iap_manage_subscriptions(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + if (argc != 0) + return enif_make_badarg(env); + + [MobIapBridge manageSubscriptions]; + return enif_make_atom(env, "ok"); +} + // Return the root view controller of the key window in the first active scene. static UIViewController *mob_root_vc(void) { for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { @@ -6651,6 +6840,12 @@ static ERL_NIF_TERM nif_bt_hid_subscribe_raw(ErlNifEnv *env, int argc, const ERL // doesn't head-of-line-block the regular schedulers. See the impl // above for the iOS rationale. {"resolve_ipv4", 1, nif_resolve_ipv4, ERL_NIF_DIRTY_JOB_IO_BOUND}, + // ── In-App Purchase (mob_iap plugin) ────────────────────────────────────── + {"iap_fetch_products", 1, nif_iap_fetch_products, 0}, + {"iap_purchase", 1, nif_iap_purchase, 0}, + {"iap_restore", 0, nif_iap_restore, 0}, + {"iap_current_entitlements", 0, nif_iap_current_entitlements, 0}, + {"iap_manage_subscriptions", 0, nif_iap_manage_subscriptions, 0}, }; static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 992343d..b757bd0 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -126,7 +126,13 @@ bt_hid_connect/1, bt_hid_subscribe_raw/1, %% DNS — see Mob.DNS and guides/dns_on_ios.md - resolve_ipv4/1 + resolve_ipv4/1, + %% In-App Purchase (mob_iap plugin) + iap_fetch_products/1, + iap_purchase/1, + iap_restore/0, + iap_current_entitlements/0, + iap_manage_subscriptions/0 ]). -nifs([ @@ -241,7 +247,13 @@ %% 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. - resolve_ipv4/1 + resolve_ipv4/1, + %% In-App Purchase (mob_iap plugin) + iap_fetch_products/1, + iap_purchase/1, + iap_restore/0, + iap_current_entitlements/0, + iap_manage_subscriptions/0 ]). -on_load(init/0). @@ -360,3 +372,9 @@ 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). +%% In-App Purchase — NIF stubs (registered by mob_iap plugin) +iap_fetch_products(_ProductIds) -> erlang:nif_error(not_loaded). +iap_purchase(_ProductId) -> erlang:nif_error(not_loaded). +iap_restore() -> erlang:nif_error(not_loaded). +iap_current_entitlements() -> erlang:nif_error(not_loaded). +iap_manage_subscriptions() -> erlang:nif_error(not_loaded).