From 5410a4d328d6ad790627a51a1789819644ab9960 Mon Sep 17 00:00:00 2001 From: Andrew Sheehan Date: Sun, 9 Aug 2026 20:20:13 -0500 Subject: [PATCH] fix: store cold-start launch notifications before nif_load (Android + iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mob_set_launch_notification (zig) and mob_set_launch_notification_json (objc) returned early when their mutex didn't exist yet — but the mutex is created in nif_load, and the cold-start path (MainActivity.onCreate / app delegate on a notification tap that launches the app) stores the payload BEFORE the BEAM boots. The tapped notification was silently discarded every time: tap-to-open from a killed app never worked on either platform. Warm and backgrounded taps (onNewIntent / running delegate) were unaffected. Store pre-mutex instead, exactly the pattern mob_set_opened_document has always used and documented: nothing reads the global until take_launch_notification, which can only run after nif_load, so the unguarded window has no concurrent reader. Post-load stores still lock. Found adversarially reviewing BuyFrost's push enablement: the opened-document sibling's comment describes the cold-launch window explicitly, which is what exposed the launch-notification variant as a latent bug rather than a design choice. --- CHANGELOG.md | 15 +++++++++++++++ android/jni/mob_nif.zig | 15 +++++++++++---- ios/mob_nif.m | 14 ++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b606a3..5068ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [Unreleased] + +### Fixed +- **Cold-start launch notifications are no longer dropped (Android + iOS).** + `mob_set_launch_notification` (Android) and `mob_set_launch_notification_json` + (iOS) bailed out when called before `nif_load` had created their mutex — which + is exactly when the cold-start path runs (MainActivity.onCreate / the app + delegate store the tapped notification before the BEAM boots). The payload + was silently discarded, so tap-to-open from a killed app never worked; a + warm/backgrounded tap (delivered via `onNewIntent` / the running delegate) + was unaffected. Both setters now store before the mutex exists, the same + pre-mutex pattern `mob_set_opened_document` has always used — safe because + nothing reads the global until `take_launch_notification`, which can only + run post-`nif_load`. + ## [0.7.20] - 2026-07-11 ### Changed diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 152bb3b..c029810 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2238,17 +2238,24 @@ export fn nif_share_text( // ── Launch notification (written from Kotlin on cold start) ────────────── // MobBridge.setLaunchNotification(json) → mob_set_launch_notification(json). // Apps call Mob.Device.take_launch_notification/0 → nif_take_launch_notification -// to consume it. Guarded by g_launch_notif_mutex (lazily created in nif_load). +// to consume it. Guarded by g_launch_notif_mutex once nif_load created it; +// stores before that are unguarded on purpose (see below). var g_launch_notif_json: ?[*:0]u8 = null; var g_launch_notif_mutex: ?*erts.ErlNifMutex = null; pub export fn mob_set_launch_notification(json: ?[*:0]const u8) callconv(.c) void { - const mutex = g_launch_notif_mutex orelse return; - erts.enif_mutex_lock(mutex); - defer erts.enif_mutex_unlock(mutex); + // Store even before nif_load created the mutex: on a cold start from a + // notification tap, MainActivity.onCreate calls this before the BEAM + // thread starts, and nothing reads the global until take_launch_notification + // (post-nif_load), so there's no concurrent access in that window. The + // previous `orelse return` silently dropped exactly that cold-start + // payload — tap-to-open from a killed app never worked. Same pattern as + // mob_set_opened_document below. + if (g_launch_notif_mutex) |mutex| erts.enif_mutex_lock(mutex); if (g_launch_notif_json) |old| jni.free(@as(?*anyopaque, @ptrCast(old))); g_launch_notif_json = if (json) |j| jni.strdup(j) else null; + if (g_launch_notif_mutex) |mutex| erts.enif_mutex_unlock(mutex); } export fn nif_take_launch_notification( diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 23ab1d6..a197b69 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2383,12 +2383,18 @@ void mob_send_push_token(const char *hex_token) { } void mob_set_launch_notification_json(const char *json) { - if (!g_launch_notif_mutex) - return; - enif_mutex_lock(g_launch_notif_mutex); + // Store even before nif_load created the mutex: on a cold start from a + // notification tap, the app delegate calls this before the BEAM starts, + // and nothing reads the global until take_launch_notification + // (post-nif_load), so there's no concurrent access in that window. The + // previous early return silently dropped exactly that cold-start payload + // — tap-to-open from a killed app never worked. + if (g_launch_notif_mutex) + enif_mutex_lock(g_launch_notif_mutex); free(g_launch_notification_json); g_launch_notification_json = json ? strdup(json) : NULL; - enif_mutex_unlock(g_launch_notif_mutex); + if (g_launch_notif_mutex) + enif_mutex_unlock(g_launch_notif_mutex); } static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc,