Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions android/jni/mob_beam.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions android/jni/mob_erts.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand Down
193 changes: 193 additions & 0 deletions android/jni/mob_nif.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", .{});
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 = .{
Expand Down
111 changes: 111 additions & 0 deletions docs/designs/background_tasks_phase2.md
Original file line number Diff line number Diff line change
@@ -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.
Loading