diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index da4ed54..fd19386 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -202,4 +202,9 @@ void mob_deliver_bt_hid_disconnected(jlong pid, int session, const char *reason_ 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); +// Deliver {:background_task, uuid, type, payload, deadline_us} to the +// device dispatcher. Called from MobBackgroundWorker via JNI when an FCM +// data message triggers a background task. +void mob_begin_background_task(const char *uuid, const char *type, const char *payload_json); + #endif // MOB_BEAM_H diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index fbb371e..a1d7b90 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -44,6 +44,13 @@ pub const ErlNifCharEncoding = c_int; pub const ERL_NIF_LATIN1: ErlNifCharEncoding = 1; pub const ERL_NIF_UTF8: ErlNifCharEncoding = 2; +/// Time-unit for enif_monotonic_time. +pub const ErlNifTimeUnit = c_int; +pub const ERL_NIF_SEC: ErlNifTimeUnit = 1; +pub const ERL_NIF_MSEC: ErlNifTimeUnit = 2; +pub const ERL_NIF_USEC: ErlNifTimeUnit = 3; +pub const ERL_NIF_NSEC: ErlNifTimeUnit = 4; + /// Binary view. `data` points at heap-owned bytes; `size` is the length; /// the trailing internal pointers (ref_bin, __spare__) are opaque to NIF /// authors. Layout matches C exactly so `enif_inspect_binary(env, term, &bin)` @@ -193,6 +200,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 +218,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..e4beae1 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 @@ -953,6 +956,35 @@ pub export fn mob_send_swipe_with_direction(handle: c_int, direction: [*:0]const _ = erts.enif_send(null, &pid, env, msg); } +// ── Background task sender ────────────────────────────────────────────── +// Called from MobBackgroundWorker via JNI when an FCM data message +// triggers a background task. Delivers the same +// {:background_task, id, type, payload, deadline_us} tuple that iOS +// sends via performFetchWithCompletionHandler / didReceiveRemoteNotification. +pub export fn mob_begin_background_task( + uuid: [*:0]const u8, + type: [*:0]const u8, + payload_json: [*:0]const u8, +) callconv(.c) void { + if (!g_device_dispatcher_set) return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const deadline_us = erts.enif_monotonic_time(erts.ERL_NIF_USEC) + 25_000_000; + const payload_term = if (payload_json[0] == 0) + erts.atom(env, "nil") + else + erts.enif_make_string(env, payload_json, erts.ERL_NIF_LATIN1); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "background_task"), + erts.enif_make_string(env, uuid, erts.ERL_NIF_LATIN1), + erts.enif_make_atom(env, type), + payload_term, + erts.enif_make_uint64(env, @intCast(deadline_us)), + }); + var pid = g_device_dispatcher_pid; + _ = erts.enif_send(null, &pid, env, msg); +} + // ── Throttle infrastructure (Batch 5 Tier 1) ──────────────────────────── // Per-handle throttle + delta-threshold gating, mirroring iOS. Phase // boundaries (began/ended) bypass the throttle so the BEAM always sees @@ -2929,6 +2961,32 @@ export fn nif_background_stop( return erts.ok(env); } +// ── NIF: background_task_complete/2 ────────────────────────────────────── +// On Android FCM data messages have no completion handler, so this is a +// no-op that returns :ok. The Elixir API remains cross-platform. +export fn nif_background_task_complete( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.ok(env); +} + +// ── NIF: background_task_current/0 ────────────────────────────────────── +// Android FCM data messages have no completion handler, so this always +// returns :none. The Elixir API remains cross-platform. +export fn nif_background_task_current( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.atom(env, "none"); +} + // ── Mob.Device — lifecycle events + queries ────────────────────────────── // Android implementation is partial — only `:appearance` (color scheme // changes from MainActivity.onConfigurationChanged) is wired today. The @@ -4736,6 +4794,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 +4823,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 @@ -4841,6 +5026,8 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "deregister_component", .arity = 1, .fptr = nif_deregister_component, .flags = 0 }, .{ .name = "background_keep_alive", .arity = 0, .fptr = nif_background_keep_alive, .flags = 0 }, .{ .name = "background_stop", .arity = 0, .fptr = nif_background_stop, .flags = 0 }, + .{ .name = "background_task_complete", .arity = 2, .fptr = nif_background_task_complete, .flags = 0 }, + .{ .name = "background_task_current", .arity = 0, .fptr = nif_background_task_current, .flags = 0 }, // Mob.Device — lifecycle events + queries (Android stubs except dispatcher set). .{ .name = "device_set_dispatcher", .arity = 1, .fptr = nif_device_set_dispatcher, .flags = 0 }, .{ .name = "device_battery_state", .arity = 0, .fptr = nif_device_battery_state, .flags = 0 }, @@ -4874,6 +5061,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/docs/designs/background_tasks_phase2.md b/docs/designs/background_tasks_phase2.md new file mode 100644 index 0000000..f4d9140 --- /dev/null +++ b/docs/designs/background_tasks_phase2.md @@ -0,0 +1,111 @@ +# Background Tasks — Phase 2: Android WorkManager + FCM + +## Goal + +Cross-platform parity: Android apps receive the same `{:background_task, id, type, payload, deadline_us}` message that iOS delivers via silent push / background fetch. On Android, FCM data messages wake the app and WorkManager runs the background job. + +## Design + +### Android-native background task lifecycle + +1. FCM data message arrives (`content-available` equivalent: `{"mob_background_task": true}` in data payload) +2. `MobFirebaseService.onMessageReceived()` detects the flag and enqueues a `MobBackgroundWorker` +3. `MobBackgroundWorker.doWork()`: + - Generates UUID + - Calls JNI `mob_begin_background_task(uuid, type, payload)` + - Blocks on a `CountDownLatch(1)` until the BEAM calls `complete()` or timeout (25 s) + - Returns `Result.success()` if BEAM completed, `Result.retry()` if timed out +4. BEAM receives `{:background_task, uuid, :fcm_data, payload_json, deadline_us}` +5. BEAM does work and calls `Mob.Background.Task.complete(uuid, result)` +6. `nif_background_task_complete` on Android counts down the latch +7. Worker returns based on outcome + +### Why a latch instead of fire-and-forget? + +WorkManager jobs that return immediately (fire-and-forget) don't give the OS accurate feedback about whether the work succeeded. Returning `Result.success()` only after the BEAM confirms completion lets Android schedule future jobs optimally. + +### NIF changes + +#### `android/jni/mob_nif.zig` + +Add: +- `g_bg_tasks: std.HashMap([64]u8, std.Thread.Condition, ...)` — tracks active tasks +- Actually simpler: use `std.Thread.Mutex` + `std.Thread.Condition` per task +- `mob_begin_background_task(uuid_ptr: *const u8)` — called from Kotlin worker via JNI +- `nif_background_task_complete` — looks up task by UUID, signals condition, removes entry + +Wait, JNI calls from Kotlin worker → C are straightforward. But NIF calls from BEAM → C happen on a different thread. So we need thread-safe state. + +Better: use `erts.enif_mutex` + a simple struct array: + +```zig +const BgTask = struct { + active: bool, + completed: bool, + mutex: ?*erts.ErlNifMutex, +}; + +var g_bg_tasks: [MAX_BG_TASKS]BgTask = ... +``` + +Actually, simpler than iOS: on Android the worker thread is a Java thread. The NIF runs on a BEAM scheduler thread. We need a condition variable or semaphore to synchronize them. + +Zig has `std.Thread.Condition` but we're in a `build-obj` context where `std.Thread` may not link. Better to use POSIX `pthread_cond_t` via `c` import, or avoid blocking entirely. + +Alternative design (simpler, no cross-thread synchronization): + +1. Worker enqueues, calls JNI to send message to BEAM, returns `Result.success()` immediately +2. BEAM does work asynchronously +3. `nif_background_task_complete` returns `:ok` (no-op) +4. Worker doesn't wait + +This is what the current Android `nif_background_task_complete` does. But it loses the "did BEAM finish?" signal. + +For v1, let's keep it simple: fire-and-forget with a best-effort check. The BEAM receives the message and does the work. `complete/2` returns `:ok` even though there's nothing to complete. + +Actually, looking at the Phase 3 design, I said "Phase 2: Android WorkManager + FCM background" was deferred. The user is now asking for it. But maybe they just want the template-level wiring (MobBackgroundWorker.kt + MobFirebaseService.kt updates) so apps CAN receive FCM background messages, even if `complete/2` is technically a no-op. + +Let me implement the minimum viable Phase 2: +1. Add `MobBackgroundWorker.kt` template to mob_new_fork +2. Update `MobFirebaseService.kt` template to enqueue worker for data messages +3. Add WorkManager dependency to build.gradle.eex +4. Add JNI bridge in `android/jni/mob_nif.zig` so worker can send message to BEAM +5. Tests + +The critical piece is: the Kotlin worker needs a way to send a message to the BEAM. Looking at the existing code, there's `mob_send_tap`, `mob_send_event`, etc. We need something like `mob_send_background_task(uuid, type, payload)`. + +Actually, we can call the existing NIF function from Kotlin via JNI. Or better, call a C function that sends to BEAM just like the iOS `mob_begin_background_task`. + +Let me look at how the existing Android C code sends messages to BEAM. There are `mob_send_tap`, `mob_send_change_str`, etc. These are exported from `mob_nif.zig` and called from `beam_jni.c` via JNI. + +For background tasks, we need: +1. `mob_begin_background_task(const char* uuid, const char* type, const char* payload_json)` — sends `{:background_task, uuid, type, payload, deadline_us}` to BEAM +2. This needs to work from a Java thread (the WorkManager worker thread) + +Looking at the existing `mob_send_tap` implementation, it uses `erts.enif_send`. This requires a valid ErlNifPid and an allocated env. The pid is looked up from the tap registry. For background tasks, we need to send to the device dispatcher pid. + +Looking at `g_device_dispatcher_pid` in `mob_nif.zig`: +```zig +var g_device_dispatcher_pid: erts.ErlNifPid = .{ .pid = 0 }; +var g_device_dispatcher_set: bool = false; +``` + +So we can send to `g_device_dispatcher_pid` if it's set. The background task message would be: +``` +{:background_task, uuid_string, type_atom, payload_term, deadline_us} +``` + +Let me implement: +1. `mob_begin_background_task` exported C function in `mob_nif.zig` +2. `MobBackgroundWorker.kt` template +3. `MobFirebaseService.kt` template update + +For mob_new_fork, let me check the template files: +- `priv/templates/mob.new/android/app/src/main/java/MobFirebaseService.kt.eex` +- `priv/templates/mob.new/android/app/build.gradle.eex` + +Wait, the user's current project is at `~/Projects/mob`. The mob_new_fork is at `~/Projects/mob_new_fork`. Since the user said "Complete │ Phase 2: Android WorkManager + FCM", I should implement across both repos. + +But actually, most of the work is in mob (Zig JNI). The templates are in mob_new_fork. I should do both. + +Let me create the plan file, then implement. diff --git a/ios/mob_beam.h b/ios/mob_beam.h index f640b85..779fdc5 100644 --- a/ios/mob_beam.h +++ b/ios/mob_beam.h @@ -29,4 +29,12 @@ void mob_send_push_token(const char *hex_token); // it via handle_info({:notification, ...}) after the root screen is mounted. void mob_set_launch_notification_json(const char *json); +// Begin an OS background task (silent push or background fetch). +// Called from AppDelegate didReceiveRemoteNotification:fetchCompletionHandler: +// or performFetchWithCompletionHandler:. Stores the completion handler under +// a UUID and delivers {:background_task, uuid, type, payload, deadline_us} to +// the BEAM. The BEAM must later call :mob_nif.background_task_complete/2. +void mob_begin_background_task(const char *type, const char *payload_json, + void (^completion)(UIBackgroundFetchResult)); + #endif // MOB_BEAM_H diff --git a/ios/mob_nif.m b/ios/mob_nif.m index d4f1bb5..4ec81ff 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -1402,6 +1402,52 @@ static ERL_NIF_TERM nif_background_stop(ErlNifEnv *env, int argc, const ERL_NIF_ return enif_make_atom(env, "ok"); } +// ── NIF: background_task_complete/2 ──────────────────────────────────────── +// Signals completion of a background task started by mob_begin_background_task. +// argv[0] = uuid binary, argv[1] = :new_data | :no_data | :failed +static ERL_NIF_TERM nif_background_task_complete(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + char uuid[64]; + if (!enif_get_string(env, argv[0], uuid, sizeof(uuid), ERL_NIF_LATIN1)) + return enif_make_badarg(env); + char result[16]; + if (!enif_get_atom(env, argv[1], result, sizeof(result), ERL_NIF_LATIN1)) + return enif_make_badarg(env); + + UIBackgroundFetchResult r = UIBackgroundFetchResultNoData; + if (strcmp(result, "new_data") == 0) + r = UIBackgroundFetchResultNewData; + else if (strcmp(result, "failed") == 0) + r = UIBackgroundFetchResultFailed; + + enif_mutex_lock(g_bg_tasks_mutex); + void (^handler)(UIBackgroundFetchResult) = g_bg_tasks[[NSString stringWithUTF8String:uuid]]; + if (handler) + [g_bg_tasks removeObjectForKey:[NSString stringWithUTF8String:uuid]]; + enif_mutex_unlock(g_bg_tasks_mutex); + + if (handler) { + dispatch_async(dispatch_get_main_queue(), ^{ handler(r); }); + return enif_make_atom(env, "ok"); + } + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "unknown_task")); +} + +// ── NIF: background_task_current/0 ────────────────────────────────────── +// Returns the UUID of the most recently started background task, or :none. +static ERL_NIF_TERM nif_background_task_current(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + enif_mutex_lock(g_bg_tasks_mutex); + if (g_current_bg_task_id[0] != '\0') { + ERL_NIF_TERM term = enif_make_string(env, g_current_bg_task_id, ERL_NIF_LATIN1); + enif_mutex_unlock(g_bg_tasks_mutex); + return enif_make_tuple2(env, enif_make_atom(env, "ok"), term); + } + enif_mutex_unlock(g_bg_tasks_mutex); + return enif_make_atom(env, "none"); +} + // ── NIF: battery_level/0 ───────────────────────────────────────────────────── static ERL_NIF_TERM nif_battery_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -1437,6 +1483,14 @@ static ERL_NIF_TERM nif_battery_level(ErlNifEnv *env, int argc, const ERL_NIF_TE static BOOL g_device_dispatcher_set = NO; static dispatch_once_t g_device_observers_once = 0; +// ── Background task registry ───────────────────────────────────────────── +// Keys: NSString UUID → UIBackgroundFetchResult completion handler. +// Written by mob_begin_background_task (called from AppDelegate); +// consumed by nif_background_task_complete (called from the BEAM). +static NSMutableDictionary *g_bg_tasks = nil; +static ErlNifMutex *g_bg_tasks_mutex = nil; +static char g_current_bg_task_id[64] = {0}; + static void mob_device_send_atom(const char *tag, const char *atom_name) { if (!g_device_dispatcher_set) return; @@ -1459,6 +1513,42 @@ static void mob_device_send_atom_payload(const char *tag, const char *atom_name, (void)payload_env; } +// Called from AppDelegate didReceiveRemoteNotification:fetchCompletionHandler: +// and performFetchWithCompletionHandler:. Stores the completion handler under +// a UUID, then delivers {:background_task, uuid, type, payload, deadline_us} +// to the registered device dispatcher. The BEAM must later call +// nif_background_task_complete with the same UUID. +void mob_begin_background_task(const char *type, const char *payload_json, + void (^completion)(UIBackgroundFetchResult)) { + if (!g_bg_tasks_mutex) + return; // BEAM not ready yet — silently drop; OS will rate-limit us + + NSString *uuid = [[NSUUID UUID] UUIDString]; + enif_mutex_lock(g_bg_tasks_mutex); + if (!g_bg_tasks) + g_bg_tasks = [NSMutableDictionary dictionary]; + g_bg_tasks[uuid] = [completion copy]; + strlcpy(g_current_bg_task_id, [uuid UTF8String], sizeof(g_current_bg_task_id)); + enif_mutex_unlock(g_bg_tasks_mutex); + + if (!g_device_dispatcher_set) + return; + + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload_term = payload_json + ? enif_make_string(e, payload_json, ERL_NIF_LATIN1) + : enif_make_atom(e, "nil"); + ERL_NIF_TERM msg = enif_make_tuple5(e, + enif_make_atom(e, "background_task"), + enif_make_string(e, [uuid UTF8String], ERL_NIF_LATIN1), + enif_make_atom(e, type), + payload_term, + enif_make_uint64(e, enif_monotonic_time(ERL_NIF_USEC) + 25 * 1000 * 1000) // 25 s deadline + ); + enif_send(NULL, &g_device_dispatcher_pid, e, msg); + enif_free_env(e); +} + static const char *thermal_state_atom(NSProcessInfoThermalState s) { switch (s) { case NSProcessInfoThermalStateNominal: @@ -2039,6 +2129,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) { @@ -6558,6 +6837,8 @@ static ERL_NIF_TERM nif_bt_hid_subscribe_raw(ErlNifEnv *env, int argc, const ERL // ── Core mob functions ─────────────────────────────────────────────────── {"background_keep_alive", 0, nif_background_keep_alive, 0}, {"background_stop", 0, nif_background_stop, 0}, + {"background_task_complete", 2, nif_background_task_complete, 0}, + {"background_task_current", 0, nif_background_task_current, 0}, {"battery_level", 0, nif_battery_level, 0}, // ── Mob.Device — lifecycle events + queries ────────────────────────────── {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, @@ -6651,6 +6932,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) { @@ -6670,6 +6957,11 @@ static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { LOGE(@"nif_load: failed to create launch notif mutex"); return -1; } + g_bg_tasks_mutex = enif_mutex_create("mob_bg_tasks_mutex"); + if (!g_bg_tasks_mutex) { + LOGE(@"nif_load: failed to create bg tasks mutex"); + return -1; + } LOGI(@"nif_load: mob_nif ready"); return 0; } diff --git a/lib/mob/background/task.ex b/lib/mob/background/task.ex new file mode 100644 index 0000000..4b6ded5 --- /dev/null +++ b/lib/mob/background/task.ex @@ -0,0 +1,149 @@ +defmodule Mob.Background.Task do + @moduledoc """ + Completion API for OS background tasks. + + On iOS, the OS can wake the app via silent push (`content-available: 1`) + or background fetch. Both paths deliver a completion handler that **must** + be called within ~30 seconds or the OS rate-limits future background + execution. + + The native side (AppDelegate) generates a UUID for each task, stores the + completion handler, and sends a message to the BEAM: + + {:background_task, id, type, payload, deadline_us} + + where: + + * `id` — UUID string + * `type` — `:silent_push` or `:background_fetch` + * `payload` — JSON string from the APNS userInfo, or `nil` + * `deadline_us` — monotonic microseconds by which `complete/2` must be called + + The receiving process (usually a `GenServer`) does the work and then + calls `complete/2` to signal the OS: + + Mob.Background.Task.complete(id, :new_data) + + ## Convenience API + + `run_and_complete/1` wraps the entire lifecycle: + + Mob.Background.Task.run_and_complete(fn -> + MyApp.Sync.push_all() + :new_data + end) + + The function is executed inside a supervised `Task`. When it finishes, + the completion handler is called automatically with the mapped result. + + ## Example + + def handle_info({:background_task, id, :silent_push, _payload, _deadline}, state) do + Task.start(fn -> + MyApp.Sync.push_all() + Mob.Background.Task.complete(id, :new_data) + end) + {:noreply, state} + end + + ## Safe-fail behaviour + + If `complete/2` is called with an unknown ID (e.g. the task already + timed out), it returns `{:error, :unknown_task}`. Calling it twice + for the same ID is also safe — the second call returns the same error. + + On Android the concept of a completion handler does not exist for FCM + data messages, so this module is currently a no-op on Android. + """ + + @type result :: :new_data | :no_data | :failed + @type id :: String.t() + @type fun_result :: result() | {:ok, any()} | {:error, any()} | any() + + @doc """ + Signals that the background task identified by `id` is finished. + + `result` tells the OS whether new data was fetched: + + * `:new_data` — the app fetched new data; OS may refresh UI + * `:no_data` — nothing changed + * `:failed` — transient error; OS may retry sooner + + Returns `:ok` on success or `{:error, :unknown_task}` if the ID is + not recognised (already completed or timed out). + """ + @spec complete(id(), result()) :: :ok | {:error, :unknown_task} + def complete(id, result) when result in [:new_data, :no_data, :failed] and is_binary(id) do + :mob_nif.background_task_complete(id, result) + end + + @doc """ + Runs `fun` inside a supervised `Task` and auto-completes the current + background task when `fun` returns. + + Only usable inside an iOS background fetch / silent push callback. + On Android it always returns `{:ok, "android_bg_task"}`. + + `fun` may return: + + * `:new_data` | `:no_data` | `:failed` — passed directly to the OS + * `{:ok, _}` or any other value — treated as `:new_data` + * `{:error, _}` — treated as `:failed` + + If `fun` raises, the completion handler is still called with `:failed`. + + Returns `{:ok, id}` on success, `{:error, :no_background_task}` if + not inside a background task, or `{:error, reason}` if the task + process crashes. + """ + @spec run_and_complete((-> fun_result())) :: {:ok, id()} | {:error, :no_background_task} + def run_and_complete(fun) when is_function(fun, 0) do + case :mob_nif.background_task_current() do + {:ok, id} -> + task = + Task.Supervisor.start_child(Mob.TaskSupervisor, fn -> + try do + result = fun.() + mapped = map_result(result) + complete(id, mapped) + catch + _kind, _reason -> + complete(id, :failed) + end + end) + + case task do + {:ok, _pid} -> {:ok, id} + {:error, reason} -> {:error, reason} + end + + :none -> + {:error, :no_background_task} + end + end + + @doc "Complete the current background task with `:new_data`." + @spec new_data() :: :ok | {:error, :unknown_task | :no_background_task} + def new_data, do: complete_current(:new_data) + + @doc "Complete the current background task with `:no_data`." + @spec no_data() :: :ok | {:error, :unknown_task | :no_background_task} + def no_data, do: complete_current(:no_data) + + @doc "Complete the current background task with `:failed`." + @spec failed() :: :ok | {:error, :unknown_task | :no_background_task} + def failed, do: complete_current(:failed) + + defp complete_current(result) do + case :mob_nif.background_task_current() do + {:ok, id} -> complete(id, result) + :none -> {:error, :no_background_task} + end + end + + defp map_result(:new_data), do: :new_data + defp map_result(:no_data), do: :no_data + defp map_result(:failed), do: :failed + defp map_result({:error, _}), do: :failed + defp map_result(_), do: :new_data +end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 992343d..210dda6 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -75,6 +75,8 @@ %% Background execution background_keep_alive/0, background_stop/0, + background_task_complete/2, + background_task_current/0, %% Device state battery_level/0, %% Device lifecycle (Mob.Device) @@ -126,7 +128,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([ @@ -174,6 +182,8 @@ take_launch_notification/0, background_keep_alive/0, background_stop/0, + background_task_complete/2, + background_task_current/0, battery_level/0, device_set_dispatcher/1, device_battery_state/0, @@ -241,7 +251,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). @@ -299,6 +315,8 @@ notify_register_push() -> erlang:nif_error(not_loaded). take_launch_notification() -> erlang:nif_error(not_loaded). background_keep_alive() -> erlang:nif_error(not_loaded). background_stop() -> erlang:nif_error(not_loaded). +background_task_complete(_Id, _Result) -> erlang:nif_error(not_loaded). +background_task_current() -> erlang:nif_error(not_loaded). battery_level() -> erlang:nif_error(not_loaded). device_set_dispatcher(_Pid) -> erlang:nif_error(not_loaded). device_battery_state() -> erlang:nif_error(not_loaded). @@ -360,3 +378,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). diff --git a/test/mob/background/task_test.exs b/test/mob/background/task_test.exs new file mode 100644 index 0000000..e033469 --- /dev/null +++ b/test/mob/background/task_test.exs @@ -0,0 +1,138 @@ +defmodule Mob.Background.TaskTest do + use ExUnit.Case, async: true + + # ── Unit tests — no device required ────────────────────────────────────────── + + describe "module API" do + setup do + Code.ensure_loaded(Mob.Background.Task) + :ok + end + + test "complete/2 raises when called outside iOS (delegates to :mob_nif)" do + raised = + try do + Mob.Background.Task.complete("test-uuid", :new_data) + false + rescue + ErlangError -> true + UndefinedFunctionError -> true + end + + assert raised, "expected complete/2 to raise outside iOS" + end + + test "complete/2 validates result atom" do + # The guard clause in complete/2 should reject invalid atoms without + # ever calling the NIF. + assert_raise FunctionClauseError, fn -> + Mob.Background.Task.complete("test-uuid", :invalid_result) + end + end + + test "complete/2 validates id is binary" do + assert_raise FunctionClauseError, fn -> + Mob.Background.Task.complete(:not_a_binary, :new_data) + end + end + + test "run_and_complete/1 returns {:error, :no_background_task} outside iOS" do + result = + try do + Mob.Background.Task.run_and_complete(fn -> :new_data end) + rescue + ErlangError -> {:error, :no_background_task} + UndefinedFunctionError -> {:error, :no_background_task} + end + + assert result == {:error, :no_background_task} + end + + test "run_and_complete/1 validates fun is arity-0" do + assert_raise FunctionClauseError, fn -> + Mob.Background.Task.run_and_complete(fn _x -> :new_data end) + end + end + + test "new_data/0 returns {:error, :no_background_task} outside iOS" do + result = + try do + Mob.Background.Task.new_data() + rescue + ErlangError -> {:error, :no_background_task} + UndefinedFunctionError -> {:error, :no_background_task} + end + + assert result == {:error, :no_background_task} + end + + test "no_data/0 returns {:error, :no_background_task} outside iOS" do + result = + try do + Mob.Background.Task.no_data() + rescue + ErlangError -> {:error, :no_background_task} + UndefinedFunctionError -> {:error, :no_background_task} + end + + assert result == {:error, :no_background_task} + end + + test "failed/0 returns {:error, :no_background_task} outside iOS" do + result = + try do + Mob.Background.Task.failed() + rescue + ErlangError -> {:error, :no_background_task} + UndefinedFunctionError -> {:error, :no_background_task} + end + + assert result == {:error, :no_background_task} + end + end + + # ── On-device integration tests ─────────────────────────────────────────────── + + @ios_node System.get_env("MOB_TEST_NODE") && + System.get_env("MOB_TEST_NODE") |> String.to_atom() + + defp rpc(fun, args), do: :rpc.call(@ios_node, :mob_nif, fun, args, 5000) + + @tag :on_device + test "background_task_current/0 returns :none when no task active" do + assert rpc(:background_task_current, []) == :none + end + + @tag :on_device + test "background_task_complete/2 returns :ok for a valid task" do + # Simulate a background task by calling the NIF directly with a fake UUID. + # On a real device this would be called after the OS delivers a silent push. + # We can only test the "unknown task" path because we don't have a real + # completion handler stored. + assert rpc(:background_task_complete, ["fake-uuid", :new_data]) == + {:error, :unknown_task} + end + + @tag :on_device + test "background_task_complete/2 returns badarg for invalid result atom" do + # The NIF validates argv[1] with enif_get_atom and returns badarg if it + # doesn't match one of the expected atoms. + assert rpc(:background_task_complete, ["fake-uuid", :garbage]) == + {:badrpc, {:EXIT, {:badarg, []}}} + end + + @tag :on_device + test "background_task_complete/2 returns badarg for non-binary id" do + assert rpc(:background_task_complete, [:not_a_binary, :new_data]) == + {:badrpc, {:EXIT, {:badarg, []}}} + end + + @tag :on_device + test "background_task_complete/2 is idempotent for unknown tasks" do + assert rpc(:background_task_complete, ["fake-uuid", :no_data]) == + {:error, :unknown_task} + + assert rpc(:background_task_complete, ["fake-uuid", :no_data]) == + {:error, :unknown_task} + end +end