All notable changes to mob_wake are documented here.

Format: Keep a Changelog. Versioning: SemVer.


[0.1.2] - 2026-10-09

Added

  • On-device self-test (MOB-418). MobWake.SelfTest implements Mob.Plugin.SelfTest and is declared in the manifest as selftest:. It makes one read-only :mob_wake_nif.platform_signal/0 call: on iOS any background_refresh_status passes; on Android the bridge must answer with has_context: true. A missing context, an unregistered bridge or the host stub's nif_not_loaded fail. Run it with mix mob.selftest from a host app (mob_dev 0.7.17).

Changed

  • Requires mob >= 0.9.15 (mix dep was ~> 0.9.1, now ~> 0.9 and >= 0.9.15), for Mob.Plugin.SelfTest; mob_version in the manifest is now ~> 0.9 (was ~> 0.9.1).
  • Android: platform_signal/0 reports an unregistered bridge. The NIF answers {:error, :bridge_not_registered} when MobWakeBridge.register() never ran or the method-ID lookup failed, {:error, :no_jni_env} when it can't get a JNIEnv, and {:error, :map_build_failed} if the result map can't be built, instead of %{} (the same answer as a host with no NIF). Mob.Wake.status/1 still reports platform_signal: %{} in those cases.

[0.1.1] - 2026-09-30

Changed

  • Re-signed with plugin envelope v2 (MOB-287). mob_dev 0.7.2+ verifies this signature before evaluating the manifest. mob_dev 0.7.0 / 0.7.1 can't read v2 signatures and report this release as invalid signature — upgrade the host app to {:mob_dev, "~> 0.7.2", only: :dev, runtime: false}. No plugin code changes.

[0.1.0] - 2026-09-19

First Hex release. Ships the full four-trigger dispatch surface (iOS BGTaskScheduler, iOS silent APNs, Android WorkManager, Android FCM data messages), verified end-to-end on a Moto G Power 5G 2024 and an iPhone SE 3rd-gen (MOB-268, MOB-271).

Fixed — post-review pass on the whole mob_wake epic (2026-09-18)

Adversarial review of every commit today found 10 findings; 4 blockers + 4 real bugs + 2 latent races all fixed in this pass:

  • SECURITY: atom-table exhaustion via push identifier — Mob.Wake.Registry.identifier_to_atom/1 now uses String.to_existing_atom (was String.to_atom). Prior code let a hostile push server mint an unbounded number of atoms from arbitrary payload content and eventually crash the BEAM. Unknown identifiers now log at warn-level and drop; push completes as :failed so iOS's opportunistic scheduler doesn't over-invest.
  • CORRECTNESS: {:ok, :no_data} misread as failure on BGTask/WorkManager path — the wake_fired handler previously flattened result == :ok to :ok and anything else to :error, which mis-reported {:ok, :no_data} as setTaskCompleted(success: false). Introduced wake_result_atom/1 that maps {:ok, :no_data} → :ok. Over days this would teach iOS to stop firing our tasks.
  • CORRECTNESS: {:error, :retry} never reached WorkManager's Result.retry() — same flatten was collapsing :retry → :error. The Zig NIF's existing retryWork branch was unreachable. wake_result_atom/1 now preserves :retry.
  • CORRECTNESS: Zig NIF MAX_ID_LEN=128 silent truncation — bumped to 256 and now REJECTS identifiers longer than that at the entry rather than silently truncating. A truncated id doesn't match Kotlin's pendingWork key; the Worker would hang to its 9-minute timeout.
  • CORRECTNESS: std.mem.startsWith prefix match on Zig atoms — replaced with an exact-match atomEquals helper. Prior code would misclassify a future :okay / :retry_soon atom as :ok / :retry.
  • CORRECTNESS: JNI exception state not cleared after CallStatic*Method — added clearPendingJniException after every JNI dispatch. A pending exception makes subsequent JNI calls (including detachCurrentThread) undefined behaviour per spec.
  • RACE: Task.Supervisor.start_child return silently ignored — now matches on {:ok, _pid} and on failure logs + completes the OS-side task as :error so the platform sees "we tried and couldn't" rather than hanging.
  • RACE: Kotlin pendingWork double-registration — swap to putIfAbsent; concurrent Worker for the same identifier returns Result.retry() rather than overwriting the prior deferred (which would leak to the 9-min timeout). Removal now uses atomic remove(key, expectedValue) compare-and-remove.
  • CORRECTNESS: state stays :running on unhandled dispatch/1 error path — wrapped the handler run in try/after so the :idle state flip runs even if run_handler_with_timeout raises for an unexpected reason (Registry unavailable, etc.).
  • RESOURCE-LEAK: enif_alloc_env NULL return unchecked in iOS send helpers — now checks and drops silently on OOM, letting the BGTask expirationHandler mark success:NO at the OS window.

Added — regression tests

  • Mob.Wake.RegistryTest — atom-table-exhaustion attack simulation: sends a {:push_fired, ...} with a never-registered identifier and asserts the atom still doesn't exist afterwards (the core of the security fix).
  • {:ok, :no_data} on scheduler path — asserts dispatch/1 returns the tuple unchanged so wake_result_atom/1 sees it. Pins the correct mapping.
  • {:error, :retry} on scheduler path — same, ensures Result.retry() is reachable.

Added — MOB-269 identifier + payload schema (mob_push coordination)

  • MobWake.wake_payload/2 — canonical builder for a mob_push-shaped payload. Returns %{title: " ", body: " ", content_available: true, data: %{"mob_wake_id" => ...}}. Same shape works for both MobPush.send(token, :ios, payload) and MobPush.send(token, :android, payload) — one payload, two send calls, no per-platform forking.
  • ADR: decisions/2026-09-18-identifier-and-payload-schema.md — anchors the mob_wake_id key convention. Any change here breaks receiver routing on both platforms; the ADR names the three sites that must be updated in lockstep (this file, iOS MobWakeDispatcher.onPushFired:, Android MobWakeFcmService.onMessageReceived).
  • 5 new tests pin the wake_payload contract: identifier stringified, content_available: true, caller :data merged, mob_wake_id overrides caller's (deliberate — key is ours), title+body defaulted to a single space to satisfy mob_push 0.2's required-fields pattern-match.
  • README: mob_push link updated (now on Hex, not "in progress"), example added.

Not in MOB-269 scope

  • Payload signing. MOB-269's Linear title mentions signing; this ADR punts on it. Current threat model is "trust OS + trust APNs/FCM channel" — same as every silent-push app. Signed payloads that let the receiver verify the sender would need a shared key between the app's server and the device; a follow-up issue.
  • mob_push cross-reference is one-way. mob_push doesn't need to know about mob_wake — its existing send/3 API accepts the shape as-is. If a MobPush.wake/3 convenience wrapper turns out worth it, that's a mob_push-side change.

Added — MOB-267 status/1 enrichment + schedule opts parse

  • Mob.Wake.flatten_opts/1 — parses schedule/2's keyword list into an explicit {earliest_ms, requires_charging, requires_unmetered} tuple before crossing the NIF boundary. Elixir owns the shape; the NIFs stay dumb dispatchers.
  • :earliest accepts a DateTime (converted to ms-from-now, past times clamped to 0) or a non-negative integer ms. Anything else raises ArgumentError at the flatten step so a typo (e.g. earliest: "5m") doesn't silently no-op.
  • :constraints keyword: :charging / :unmetered bool flags. Honored on :processing (BGProcessingTaskRequest.requiresExternalPower / requiresNetworkConnectivity; WorkManager Constraints.Builder). Ignored on :refresh — both platforms.
  • schedule/5 NIF (was schedule/3) on both iOS and Android: identifier, trigger, earliest_ms, charging_bool_atom, unmetered_bool_atom.
  • platform_signal/0 NIF on both platforms; called by Mob.Wake.status/1 and merged into the returned map's :platform_signal.
    • iOS: %{background_refresh_status: :available | :denied | :restricted} — wraps UIApplication.backgroundRefreshStatus (main-thread-only, so dispatch_sync when called off-main).

    • Android: %{battery_optimized: bool, has_context: bool} — wraps PowerManager.isIgnoringBatteryOptimizations(packageName). Bit-packed long return from Kotlin (bit 0 = optimized, bit 1 = hasContext) keeps the JNI boundary simple. has_context: false means the bridge hasn't been given an app context yet — signal to the caller that "we can't tell" is distinct from "battery optimization is off".
  • 2 new tests pin the flatten_opts contract: DateTime + non_neg_integer accepted, malformed :earliest raises with a clear message.

Added — MOB-264 Android FCM data-message receive

  • priv/native/android/MobWakeFcmService.kt — FirebaseMessagingService subclass. onMessageReceived extracts data["mob_wake_id"] as the identifier and forwards the whole data map (JSON-serialised) to the bridge. onNewToken forwards the FCM registration token.
  • MobWakeBridge gains onPushFired(identifier, payloadJson) (Kotlin, called by the service) which invokes the new nativeDeliverPush(id, push_id, payloadJson) JNI thunk on the Zig NIF. push_id is a Kotlin-generated UUID — matches the iOS shape where multiple in-flight pushes per identifier are supported.
  • MobWakeBridge gains onFcmTokenRefresh(token) which invokes nativeDeliverFcmToken(token).
  • Zig NIF gains nativeDeliverPush and nativeDeliverFcmToken JNI exports. Both send to the dispatcher pid or drop if not set (FCM has ~10s process budget on Android — matches the iOS silent-APNs "fail fast on cold start" choice).
  • Zig NIF gains a no-op complete_push/2 so the Elixir Registry can call it uniformly on both platforms without a per-platform branch. FCM has no completion callback so the completion signal isn't observable to the server; kept in the shape for API cleanliness.
  • Mob.Wake.Registry gains handle_info({:mob_wake_fcm_token, token}, _) — currently logs at info; a per-pid subscription API for the token is a follow-up.
  • Manifest: com.google.firebase:firebase-messaging:23.4.1 added to gradle_deps. Host app still needs google-services.json (that's app-specific config, not plugin-owned).
  • Server-side FCM data-message convention: data.mob_wake_id names the identifier (matches the iOS userInfo.mob_wake_id convention so mob_push can send the same shape across both platforms).

Known / deferred for MOB-264

  • FCM service registration in host AndroidManifest.xml is manual until MOB-265 codegen writes it. Snippet is in the MobWakeFcmService.kt @file header. Also requires com.google.gms.google-services gradle plugin applied at app level with google-services.json in app/ — that's Firebase setup, out of mob_wake's scope.
  • FCM cold-start-via-push (BEAM asleep when push arrives) drops the push. Same trade-off as iOS silent APNs, same reason. A follow-up can add a native-side pending queue with a native timer if real workloads hit this.
  • Not physical-device-verified. MOB-268 covers physical iOS + Android verification contiguously.

Added — MOB-263 Android WorkManager wiring

  • priv/native/jni/mob_wake_nif.zig — Zig NIF exposing the same 4 entry points as the iOS side (set_dispatcher_pid, take_pending_wakes, complete_task, schedule) plus two JNI thunks (nativeRegister, nativeDeliverWake) the Kotlin bridge calls. Fixed-capacity pending-wake queue (32 identifiers × 128 bytes) mirrors the iOS pattern.
  • priv/native/android/MobWakeBridge.kt — Kotlin bridge (object) with nativeRegister/nativeDeliverWakeexternals plusscheduleWork,completeWork,retryWork, andawaitBeamDispatch(coroutine that Workers use to hand off to BEAM and await result). WorkManager enqueue usesenqueueUniqueWork(identifier, REPLACE, ...)so re-scheduling supersedes the prior pending. -priv/native/android/MobWakeWorker.kt— thinCoroutineWorkersubclass. Standard(Context, WorkerParameters)constructor lets WorkManager's default factory instantiate — no customConfiguration.Providerrequired on the hostApplication. - Result mapping: Elixir:ok→Result.success(),→Result.retry()(goes back to WorkManager for backoff), any other→Result.failure(). - Timeout: bridge waits 9 minutes (a bit under WorkManager's practical 10-minute per-Worker upper bound) before falling back toResult.failure()— surfaces a real timeout rather than letting WorkManager kill us and mis-attribute. - Manifest:mobwake_nifNIF declared for:androidplatform (Zig); Kotlin bridge registered viabridge_kt+bridge_class;androidx.work:work-runtime-ktx:2.9.0gradle dep (2.9.0 is the last that compiles cleanly against Kotlin 1.9 — mob's current Android baseline). -Mob.Wake.dispatch/1spec widened to:ok | {:ok, :no_data} | {:error, term()}—was already the documented push-handler convention but wasn't reachable throughrun_handler_with_timeout; the extra match clause now propagates it end-to-end. 1 new test pins the shape. ### Known / deferred for MOB-263 - Not physical-device-verified — that's MOB-268. Kotlin + Zig compile via mob's Android build path at host-app deploy time; unit-testable Elixir wiring is proven (25 tests green on host). -schedule/3's opts keyword parsing is still a TODO in both NIFs. Java-side defaults (earliestDelayMs=0, no constraints) are what a first-fire uses; MOB-267 wires the full parse. - FCM data-message handling (:pushtrigger) lands in MOB-264 — same bridge file, adds aMobWakeFcmServiceFirebaseMessagingService subclass and anativeDeliverPushJNI thunk. -MobWakeBridge.setAppContextneeds to be called at app start forscheduleWorkto succeed before any Worker has fired. Mob's Android framework should invoke this via the bridge_class registration flow (per the manifest); if that surface doesn't exist yet the Worker's ownsetAppContext(applicationContext)self-heal covers the fire-then-schedule case. MOB-268 physical verification will surface any gap. ### Added — MOB-262 iOS silent APNs receive -mob_wake_nif.mgains two entry points:+onPushFired:completionHandler:(ObjC, called from AppDelegate'sdidReceiveRemoteNotification:fetchCompletionHandler:) andcomplete_push/2NIF (Elixir → native →UIBackgroundFetchResultcompletion). - Nativeg_push_completionstable keyed byNSUUIDstring per push (multiple simultaneous pushes for the same identifier are supported, unlike BGTasks where one identifier maps to one fire in flight). - APNs payload is serialised to a JSON binary viaNSJSONSerializationand delivered to Elixir as-is — mob_wake doesn't take on a JSON-library dep, handlers decode themselves. - Payload routing: top-levelmob_wake_idkey in userInfo names the identifier. Pushes without it get.noDataback to iOS (so the opportunistic scheduler doesn't overinvest). -Mob.Wake.Registry.handle_info({:push_fired, id_bin, push_id, payload_json}, )— spawns dispatch underMob.Wake.TaskSupervisorand maps result toUIBackgroundFetchResult::ok→.newdata,→.no_data,{:error, }→.failed. - README AppDelegate snippet extended with the silent-APNs handler +UIBackgroundModesremote-notification+ server-side payload shape. ### Known limitation - Cold-start-via-push (BEAM not yet up when the push arrives) fails fast with.failedrather than queueing. Silent APNs has a ~30s completion window that would frequently miss a cold BEAM boot; failing fast lets the sender's server see the miss clearly. A follow-up can add push queueing with a native timer if the cold-start-via-push case turns out to matter for real workloads. ### Added — MOB-261 iOS BGTaskScheduler NIF -priv/native/ios/mobwake_nif.m— ObjC NIF wrappingBGTaskScheduler. Four entry points:set_dispatcher_pid/1,take_pending_wakes/0,complete_task/2,schedule/3. - Native-side state (mutex-guarded, mirrors mob'sg_launch_notification_jsonpattern): dispatcher pid,BGTask*table keyed by identifier, pending-wake queue for cold-start-into-background firings. -MobWakeDispatcherObjC class —+registerTaskWithIdentifier:trigger:forAppDelegateto call atdidFinishLaunchingWithOptions;+onTaskFired:is the launchHandler forwarder. -Mob.Wake.Registryinit handoff:set_dispatcher_pid(self())+ drainstake_pending_wakes(), all catches-guarded so host + Android-only builds still boot cleanly. -Mob.Wake.Registry.handle_info({:wake_fired, id}, )— spawns a task underMob.Wake.TaskSupervisorthat runsdispatch/1and callscomplete_task/2so iOS'ssetTaskCompleted(success:)reflects the true return. -expirationHandleron everyBGTask— if iOS cancels early wesetTaskCompletedWithSuccess:NOand drop the reference under the mutex; no dangling task pointer. - Manifest declares the ObjC NIF (platform: :ios,lang: :objc). - README AppDelegate snippet — the hand-wired integration until MOB-265's codegen lands. - 2 registry tests exercising thehandler throughTask.Supervisoron host (NIF absent, catches hold, dispatch runs). ### Notes - Not physical-device verified yet — that's MOB-268's dedicated harness. -schedule/3ignores theoptskeyword list (TODO: parse:earliest, constraints on:processing`). Marked in the NIF source. - Android side (MOB-263/264) still absent; NIF calls from Elixir Registry catch cleanly on those builds. ### Pre-history — scaffold (2026-09-18, git-only, never on Hex) Initial commit landed the mix.exs / manifest / Elixir dispatch surface only; the native trigger paths (MOB-261..264) came in subsequent commits. That scaffold was kept off Hex deliberately until the full four-trigger surface + physical-device verification landed. Everything above is what 0.1.0 actually ships.