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,