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.SelfTestimplementsMob.Plugin.SelfTestand is declared in the manifest asselftest:. It makes one read-only:mob_wake_nif.platform_signal/0call: on iOS anybackground_refresh_statuspasses; on Android the bridge must answer withhas_context: true. A missing context, an unregistered bridge or the host stub'snif_not_loadedfail. Run it withmix mob.selftestfrom 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), forMob.Plugin.SelfTest;mob_versionin the manifest is now~> 0.9(was~> 0.9.1). - Android:
platform_signal/0reports an unregistered bridge. The NIF answers{:error, :bridge_not_registered}whenMobWakeBridge.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/1still reportsplatform_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/1now usesString.to_existing_atom(wasString.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:failedso iOS's opportunistic scheduler doesn't over-invest. - CORRECTNESS:
{:ok, :no_data}misread as failure on BGTask/WorkManager path — the wake_fired handler previously flattenedresult == :okto:okand anything else to:error, which mis-reported{:ok, :no_data}assetTaskCompleted(success: false). Introducedwake_result_atom/1that maps{:ok, :no_data}→:ok. Over days this would teach iOS to stop firing our tasks. - CORRECTNESS:
{:error, :retry}never reached WorkManager'sResult.retry()— same flatten was collapsing:retry→:error. The Zig NIF's existingretryWorkbranch was unreachable.wake_result_atom/1now preserves:retry. - CORRECTNESS: Zig NIF
MAX_ID_LEN=128silent 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'spendingWorkkey; the Worker would hang to its 9-minute timeout. - CORRECTNESS:
std.mem.startsWithprefix match on Zig atoms — replaced with an exact-matchatomEqualshelper. Prior code would misclassify a future:okay/:retry_soonatom as:ok/:retry. - CORRECTNESS: JNI exception state not cleared after
CallStatic*Method— addedclearPendingJniExceptionafter every JNI dispatch. A pending exception makes subsequent JNI calls (includingdetachCurrentThread) undefined behaviour per spec. - RACE:
Task.Supervisor.start_childreturn silently ignored — now matches on{:ok, _pid}and on failure logs + completes the OS-side task as:errorso the platform sees "we tried and couldn't" rather than hanging. - RACE: Kotlin
pendingWorkdouble-registration — swap toputIfAbsent; concurrent Worker for the same identifier returnsResult.retry()rather than overwriting the prior deferred (which would leak to the 9-min timeout). Removal now uses atomicremove(key, expectedValue)compare-and-remove. - CORRECTNESS: state stays
:runningon unhandleddispatch/1error path — wrapped the handler run intry/afterso the:idlestate flip runs even ifrun_handler_with_timeoutraises for an unexpected reason (Registry unavailable, etc.). - RESOURCE-LEAK:
enif_alloc_envNULL return unchecked in iOS send helpers — now checks and drops silently on OOM, letting the BGTask expirationHandler marksuccess:NOat 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 — assertsdispatch/1returns the tuple unchanged sowake_result_atom/1sees it. Pins the correct mapping.{:error, :retry}on scheduler path — same, ensuresResult.retry()is reachable.
Added — MOB-269 identifier + payload schema (mob_push coordination)
MobWake.wake_payload/2— canonical builder for amob_push-shaped payload. Returns%{title: " ", body: " ", content_available: true, data: %{"mob_wake_id" => ...}}. Same shape works for bothMobPush.send(token, :ios, payload)andMobPush.send(token, :android, payload)— one payload, two send calls, no per-platform forking.- ADR:
decisions/2026-09-18-identifier-and-payload-schema.md— anchors themob_wake_idkey convention. Any change here breaks receiver routing on both platforms; the ADR names the three sites that must be updated in lockstep (this file, iOSMobWakeDispatcher.onPushFired:, AndroidMobWakeFcmService.onMessageReceived). - 5 new tests pin the wake_payload contract: identifier stringified,
content_available: true, caller:datamerged,mob_wake_idoverrides caller's (deliberate — key is ours), title+body defaulted to a single space to satisfymob_push0.2's required-fields pattern-match. - README:
mob_pushlink 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_pushcross-reference is one-way.mob_pushdoesn't need to know aboutmob_wake— its existingsend/3API accepts the shape as-is. If aMobPush.wake/3convenience 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— parsesschedule/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.:earliestaccepts aDateTime(converted to ms-from-now, past times clamped to 0) or a non-negative integer ms. Anything else raisesArgumentErrorat the flatten step so a typo (e.g.earliest: "5m") doesn't silently no-op.:constraintskeyword::charging/:unmeteredbool flags. Honored on:processing(BGProcessingTaskRequest.requiresExternalPower / requiresNetworkConnectivity; WorkManager Constraints.Builder). Ignored on:refresh— both platforms.schedule/5NIF (wasschedule/3) on both iOS and Android: identifier, trigger, earliest_ms, charging_bool_atom, unmetered_bool_atom.platform_signal/0NIF on both platforms; called byMob.Wake.status/1and merged into the returned map's:platform_signal.iOS:
%{background_refresh_status: :available | :denied | :restricted}— wrapsUIApplication.backgroundRefreshStatus(main-thread-only, sodispatch_syncwhen called off-main).- Android:
%{battery_optimized: bool, has_context: bool}— wrapsPowerManager.isIgnoringBatteryOptimizations(packageName). Bit-packed long return from Kotlin (bit 0 = optimized, bit 1 = hasContext) keeps the JNI boundary simple.has_context: falsemeans 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
:earliestraises with a clear message.
Added — MOB-264 Android FCM data-message receive
priv/native/android/MobWakeFcmService.kt—FirebaseMessagingServicesubclass.onMessageReceivedextractsdata["mob_wake_id"]as the identifier and forwards the whole data map (JSON-serialised) to the bridge.onNewTokenforwards the FCM registration token.MobWakeBridgegainsonPushFired(identifier, payloadJson)(Kotlin, called by the service) which invokes the newnativeDeliverPush(id, push_id, payloadJson)JNI thunk on the Zig NIF.push_idis a Kotlin-generated UUID — matches the iOS shape where multiple in-flight pushes per identifier are supported.MobWakeBridgegainsonFcmTokenRefresh(token)which invokesnativeDeliverFcmToken(token).- Zig NIF gains
nativeDeliverPushandnativeDeliverFcmTokenJNI 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/2so 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.Registrygainshandle_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.1added togradle_deps. Host app still needsgoogle-services.json(that's app-specific config, not plugin-owned). - Server-side FCM data-message convention:
data.mob_wake_idnames the identifier (matches the iOSuserInfo.mob_wake_idconvention somob_pushcan send the same shape across both platforms).
Known / deferred for MOB-264
- FCM service registration in host
AndroidManifest.xmlis manual until MOB-265 codegen writes it. Snippet is in theMobWakeFcmService.kt@file header. Also requirescom.google.gms.google-servicesgradle plugin applied at app level withgoogle-services.jsoninapp/— 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) withnativeRegister/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.