From f6e5abf4cb54c2904101c1958c51a994c1282d5d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 8 Oct 2026 23:43:49 -0600 Subject: [PATCH 1/3] MOB-418: on-device self-test (MobWake.SelfTest) One read-only :mob_wake_nif.platform_signal/0 call. iOS: any background_refresh_status passes. Android: passes only with has_context: true; the Zig NIF now answers {:error, :bridge_not_registered} / {:error, :no_jni_env} instead of %{} so an unregistered bridge can't look healthy. Mob.Wake.status/1 folds those into %{} as before. --- AGENTS.md | 1 + CHANGELOG.md | 22 +++++++++ README.md | 2 + lib/mob_wake/self_test.ex | 73 ++++++++++++++++++++++++++++ lib/mob_wake/wake.ex | 11 +++-- mix.exs | 4 +- mix.lock | 10 ++-- priv/mob_plugin.exs | 5 +- priv/native/jni/mob_wake_nif.zig | 32 +++++++------ test/mob_wake_test.exs | 81 ++++++++++++++++++++++++++++++-- 10 files changed, 211 insertions(+), 30 deletions(-) create mode 100644 lib/mob_wake/self_test.ex diff --git a/AGENTS.md b/AGENTS.md index 928835f..7b0f69b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ The rule of thumb: whenever a doc claims a task will run, ask yourself "even for * `lib/mob_wake.ex` — top-level @moduledoc + type aliases. Reliability story lives here canonically. * `lib/mob_wake/wake.ex` — `Mob.Wake` public API. All function bodies raise until the corresponding MOB-260..267 issue lands. +* `lib/mob_wake/self_test.ex` — `MobWake.SelfTest` (`Mob.Plugin.SelfTest`, the manifest's `selftest:`), run by `mix mob.selftest` / mob_ci. One read-only `platform_signal/0` call; on Android it fails unless the bridge is registered and has an app context, so keep the Zig NIF answering `{:error, :bridge_not_registered}` rather than `%{}` when the bridge isn't there. * `priv/mob_plugin.exs` — plugin manifest. Declares `BackgroundTasks` + `UserNotifications` frameworks on iOS, `POST_NOTIFICATIONS` on Android, empty `screens` / `nifs`. `nifs` gets populated per MOB-261..264. * `priv/native/ios/` — iOS NIF sources (MOB-261 + MOB-262). Objective-C, wraps BGTaskScheduler + `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`. * `priv/native/jni/` — Android JNI NIF sources (MOB-263 + MOB-264). Zig, exports the bridge symbols the Kotlin side calls. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acd1f7..db4fae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S --- +## [Unreleased] + +### 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). Requires mob 0.9.15; `mob_version` in + the manifest is now `~> 0.9`. + +### Changed + +- **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, and + `{:error, :no_jni_env}` when it can't get a JNIEnv, 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 diff --git a/README.md b/README.md index 2e4f000..8ede43d 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ Under the `Mob.Wake` namespace. Full contract in the module's @moduledoc. * `Mob.Wake.status/1` — health + platform-specific reliability signals * `Mob.Wake.pending/0` — inventory of currently pending fires +`MobWake.SelfTest` is the plugin's on-device self-test: run `mix mob.selftest` from a host app (mob_dev 0.7.17+) to prove the NIF and the Android bridge answer on a device. + ## Related plugins * [`mob_push`](https://hexdocs.pm/mob_push) — the *send* side of silent APNs / FCM. When you want deterministic wake timing, `mob_push` sends the trigger and `mob_wake` receives it. Use `MobWake.wake_payload/2` to build the payload for `MobPush.send/3` — same shape on both platforms (see [the identifier-and-payload ADR](decisions/2026-09-18-identifier-and-payload-schema.md)). diff --git a/lib/mob_wake/self_test.ex b/lib/mob_wake/self_test.ex new file mode 100644 index 0000000..06a2113 --- /dev/null +++ b/lib/mob_wake/self_test.ex @@ -0,0 +1,73 @@ +defmodule MobWake.SelfTest do + @moduledoc """ + The plugin's on-device proof (`Mob.Plugin.SelfTest`), run by + `mix mob.selftest` and mob_ci for every activated plugin. + + One read-only native call, no UI, nothing scheduled: `:mob_wake_nif.platform_signal/0`. + + * **iOS** — the Objective-C NIF reads + `UIApplication.backgroundRefreshStatus` on the main thread and answers + `%{background_refresh_status: :available | :denied | :restricted}`. + Any of the three passes: the answer can only come from the linked NIF + asking UIKit. `:denied` / `:restricted` are the user's or MDM's + Background App Refresh setting, not a plugin fault. + * **Android** — the Zig NIF calls the Kotlin + `MobWakeBridge.platformSignal()` over JNI and answers + `%{battery_optimized: boolean, has_context: boolean}`. It passes only + with `has_context: true`: that proves the NIF is linked, the bootstrap + ran `MobWakeBridge.register()` (the JNI class + method IDs are cached) + and handed the bridge the app context (`setActivity/1`), which is what + `Mob.Wake.schedule/2` needs to reach WorkManager. `battery_optimized` is + the user's battery setting either way. + * `has_context: false` fails: the bridge never got a context, so + scheduling can't reach WorkManager in this host. + * `{:error, :bridge_not_registered}` (Android: `register()` never ran or + the `platformSignal` method-ID lookup failed) and + `{:error, :no_jni_env}` fail. + * The other platform's map, an empty map, or anything else fails. + + The host stub's `nif_not_loaded` is a failure. Nothing here needs + hardware or the user, so the test never skips. + """ + @behaviour Mob.Plugin.SelfTest + + @ios_statuses [:available, :denied, :restricted] + + @impl true + def run(%{platform: platform}) do + classify(platform, :mob_wake_nif.platform_signal()) + rescue + e in ErlangError -> + {:fail, "mob_wake_nif is not linked into this build: #{Exception.message(e)}"} + end + + @doc false + # Maps platform_signal/0's native answer on `platform` to a self-test result. + @spec classify(:ios | :android, term()) :: Mob.Plugin.SelfTest.result() + def classify(:ios, %{background_refresh_status: status}) when status in @ios_statuses, + do: :pass + + def classify(:android, %{battery_optimized: optimized, has_context: true}) + when is_boolean(optimized), + do: :pass + + def classify(:android, %{battery_optimized: optimized, has_context: false}) + when is_boolean(optimized), + do: {:fail, "MobWakeBridge has no app context (MobActivityAware.setActivity never called)"} + + def classify(:android, {:error, :bridge_not_registered}), + do: + {:fail, + "Kotlin MobWakeBridge not registered (nativeRegister never ran or a method-ID lookup failed)"} + + def classify(:android, {:error, :no_jni_env}), + do: {:fail, "platform_signal/0 could not get a JNIEnv on this thread"} + + def classify(platform, other) do + {:fail, + "platform_signal/0 on #{platform} returned #{inspect(other)}, expected #{expected(platform)}"} + end + + defp expected(:ios), do: "%{background_refresh_status: :available | :denied | :restricted}" + defp expected(:android), do: "%{battery_optimized: boolean, has_context: boolean}" +end diff --git a/lib/mob_wake/wake.ex b/lib/mob_wake/wake.ex index 7ec8997..ef300cd 100644 --- a/lib/mob_wake/wake.ex +++ b/lib/mob_wake/wake.ex @@ -227,11 +227,16 @@ defmodule Mob.Wake do # Fetches the per-platform reliability signal via the NIF. # On host / iOS-below-13 / non-mob-Android builds the NIF is absent - # and we return %{}. Callers should treat an empty map as "we can't + # and we return %{}. On Android the NIF answers `{:error, reason}` + # when the Kotlin bridge isn't registered or there's no JNIEnv; that + # also folds to %{}. Callers should treat an empty map as "we can't # tell right now" — same shape as the platform_signal starting - # value. + # value. `MobWake.SelfTest` is what turns those answers into failures. defp platform_signal do - :mob_wake_nif.platform_signal() + case :mob_wake_nif.platform_signal() do + %{} = signal -> signal + {:error, _reason} -> %{} + end catch :error, :undef -> %{} :error, :nif_not_loaded -> %{} diff --git a/mix.exs b/mix.exs index 1de107d..0e24e20 100644 --- a/mix.exs +++ b/mix.exs @@ -42,8 +42,8 @@ defmodule MobWake.MixProject do defp deps do [ - {:mob, "~> 0.9.1"}, - {:mob_dev, "~> 0.6", only: [:dev, :test], runtime: false}, + {:mob, "~> 0.9 and >= 0.9.15"}, + {:mob_dev, "~> 0.7.17", only: [:dev, :test], runtime: false}, # Code quality — Credo + ex_slop (AI-pattern checks) + jump_credo_checks, # mirroring mob core's pre-commit gate. {:ex_doc, "~> 0.34", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index f5566d9..b27707c 100644 --- a/mix.lock +++ b/mix.lock @@ -4,7 +4,7 @@ "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"}, - "ex_ast": {:hex, :ex_ast, "0.13.1", "b3d80ec163733176f63662ac44d2511445c224f6b5e4e3ce01f5eff83c4a5993", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bd15f68cde5ec945b859bd67416f26cf5499f1aef9b067eb2163ed244c7e703a"}, + "ex_ast": {:hex, :ex_ast, "0.16.0", "cc7580f80fe585fd13cbce01b9092fd78100138440929d9f532a44b0182ea8c6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "700303b0cb693cb36dbac3a2f66a609282b108114fa20179395f36a13a9043b4"}, "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "ex_slop": {:hex, :ex_slop, "0.4.4", "8ca277b902e93193eb3545e414c309026a51204e5eb7d6ada5ff96555de6abcb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "03ef1b2553ebad45c8ceb3e482eed5f1ccd08fc992215a9da0ef6196935107e2"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -20,8 +20,8 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, - "mob": {:hex, :mob, "0.9.4", "ed50d55564fc3615a7a89f3e0b04791c990ce3a2341bfc325ee636c21dd79f82", [:mix], [{:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "821cc833f52c1c8b7abde9d2908314419c3589167d0d42e526991ef86de81f8d"}, - "mob_dev": {:hex, :mob_dev, "0.7.2", "5ed826b4b73a0201385f00f837392d2b73dfe4352148f5b88ac4f066f6430c86", [:mix], [{:avatarz, "~> 0.2", [hex: :avatarz, repo: "hexpm", optional: true]}, {:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: false]}, {:eqrcode, "~> 0.2", [hex: :eqrcode, repo: "hexpm", optional: false]}, {:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: false]}, {:image, "~> 0.54", [hex: :image, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mix_audit, "~> 2.1", [hex: :mix_audit, repo: "hexpm", optional: false]}, {:mob, "~> 0.7.25 or ~> 0.8.1 or ~> 0.9.0", [hex: :mob, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm", "6c16452a0b67d5d3e683fdc3bb2bc5dd37be2518750e67ab6da6d87b6c2833c2"}, + "mob": {:hex, :mob, "0.9.15", "496af7d9bb90ce8d8cc51bab2de5bf86e582561c645819e2a5f547d320d17526", [:mix], [{:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a9d5a1a2140697379e229ab42e2f39e93b646c8fc07dc42e294d7b3a5226075e"}, + "mob_dev": {:hex, :mob_dev, "0.7.17", "d7d2df32142c456633c37f9d431e9841a3082e8307073db7f087d525d2a86a0b", [:mix], [{:avatarz, "~> 0.2", [hex: :avatarz, repo: "hexpm", optional: true]}, {:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: false]}, {:eqrcode, "~> 0.2", [hex: :eqrcode, repo: "hexpm", optional: false]}, {:igniter, "~> 0.8", [hex: :igniter, repo: "hexpm", optional: false]}, {:image, "~> 0.54", [hex: :image, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mix_audit, "~> 2.1", [hex: :mix_audit, repo: "hexpm", optional: false]}, {:mob, "~> 0.7.25 or ~> 0.8.1 or ~> 0.9.0", [hex: :mob, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm", "c858872295c0a0ec88a2276099c1c47c36a48c0a283eafb74447a935f1f6c6fd"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -29,11 +29,11 @@ "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.1", "2ff3e7ba8b432120989870997b9adaefb76e1f63805554ab0b9e948987981f4b", [:mix], [], "hexpm", "352fba7a87bf61395b6e97da66944ad9b1bfe5459e5981039f60d68270bd9700"}, "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, - "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, + "req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "sourceror": {:hex, :sourceror, "1.12.3", "f58eebef0765c7a369a49a755ab2a5ee88d92777f403ea5a08b722cefcc37f51", [:mix], [], "hexpm", "d5f2f37099de794840f08c54ae546d7f6e4ea015e397be64aebe4996fa9f7da7"}, "spitfire": {:hex, :spitfire, "0.4.2", "5c719208d4eeb810e5b2a2aa1024d1e4b2974b7ca422c274b2486666e5159735", [:mix], [], "hexpm", "9bbbbffe93e6f88ccf193487ef56b83c2646a6dc3875bb0975d4654bfe96c5bb"}, diff --git a/priv/mob_plugin.exs b/priv/mob_plugin.exs index 355ff03..1fcde16 100644 --- a/priv/mob_plugin.exs +++ b/priv/mob_plugin.exs @@ -1,7 +1,10 @@ %{ name: :mob_wake, - mob_version: "~> 0.9.1", + mob_version: "~> 0.9", plugin_spec_version: 1, + # On-device proof for `mix mob.selftest` / mob_ci: one read-only + # platform_signal/0 round trip through the NIF (see MobWake.SelfTest). + selftest: MobWake.SelfTest, # Description lives in mix.exs (Hex's source of truth). The plugin manifest # schema does not accept a top-level :description key today. # diff --git a/priv/native/jni/mob_wake_nif.zig b/priv/native/jni/mob_wake_nif.zig index 4675ee7..4d57223 100644 --- a/priv/native/jni/mob_wake_nif.zig +++ b/priv/native/jni/mob_wake_nif.zig @@ -405,28 +405,30 @@ fn nif_complete_push_noop(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const ert return erts.ok(env); } +// {error, Reason} for the paths where the NIF is linked but cannot ask the +// bridge. Mob.Wake.status/1 folds these into an empty map ("can't tell right +// now"); MobWake.SelfTest turns them into failures (MOB-418). +fn errorTuple(env: ?*erts.ErlNifEnv, comptime reason: [:0]const u8) erts.ERL_NIF_TERM { + return erts.makeTuple(env, .{ erts.atom(env, "error"), erts.atom(env, reason) }); +} + // platform_signal/0 → %{battery_optimized: bool, has_context: bool} -// Wraps MobWakeBridge.platformSignal() which returns a bit-packed long: +// Wraps MobWakeBridge.platformSignal() which returns a bit-packed int: // bit 0 = batteryOptimized, bit 1 = hasContext. `has_context` distinguishes // "app is whitelisted" from "we can't tell yet" (bridge boot race). +// +// {error, bridge_not_registered} when MobWakeBridge.register() never ran +// (nativeRegister never cached the class) or the platformSignal method-ID +// lookup failed; {error, no_jni_env} when this thread can't get a JNIEnv. +// These used to return %{} — the same answer as a host with no NIF — so a +// host app whose bootstrap never registered the bridge looked healthy. fn nif_platform_signal(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const erts.ERL_NIF_TERM) callconv(.c) erts.ERL_NIF_TERM { _ = argc; _ = argv; - // Empty-map default for the "can't tell right now" paths (NIF loaded - // but JNI env unavailable or bridge not registered). Same shape the - // Elixir side falls back to on host when the NIF itself is absent. - var empty_keys: [0]erts.ERL_NIF_TERM = undefined; - var empty_vals: [0]erts.ERL_NIF_TERM = undefined; - var empty_map: erts.ERL_NIF_TERM = undefined; - _ = erts.enif_make_map_from_arrays(env, &empty_keys, &empty_vals, 0, &empty_map); - + if (g_wake_cls == null or g_wake.platform_signal == null) return errorTuple(env, "bridge_not_registered"); var attached: c_int = 0; - const jenv = get_jenv(&attached) orelse return empty_map; - if (g_wake_cls == null or g_wake.platform_signal == null) { - detachIfAttached(attached); - return empty_map; - } + const jenv = get_jenv(&attached) orelse return errorTuple(env, "no_jni_env"); const bits = jenv.*.CallStaticIntMethod.?(jenv, g_wake_cls, g_wake.platform_signal); clearPendingJniException(jenv); detachIfAttached(attached); @@ -445,7 +447,7 @@ fn nif_platform_signal(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const erts.E const hc_atom = if (has_context) erts.atom(env, "true") else erts.atom(env, "false"); var vals = [_]erts.ERL_NIF_TERM{ bo_atom, hc_atom }; var out: erts.ERL_NIF_TERM = undefined; - if (erts.enif_make_map_from_arrays(env, &keys, &vals, keys.len, &out) == 0) return empty_map; + if (erts.enif_make_map_from_arrays(env, &keys, &vals, keys.len, &out) == 0) return errorTuple(env, "map_build_failed"); return out; } diff --git a/test/mob_wake_test.exs b/test/mob_wake_test.exs index 3e8afbd..1e64ef2 100644 --- a/test/mob_wake_test.exs +++ b/test/mob_wake_test.exs @@ -2,6 +2,7 @@ defmodule MobWakeTest do use ExUnit.Case, async: true alias MobDev.Plugin.{Manifest, Validator} + alias MobWake.SelfTest @plugin_dir Path.expand("..", __DIR__) @@ -19,10 +20,10 @@ defmodule MobWakeTest do assert %{errors: []} = Validator.validate_plugin(m, @plugin_dir) end - test "requires mob 0.9.1 or newer", %{manifest: m} do - # mob 0.9.1 is the first release with the plugin manifest schema mob_wake - # depends on. Older mobs don't validate our manifest shape. - assert m.mob_version == "~> 0.9.1" + test "declares the self-test, which passes the validator without a warning", %{manifest: m} do + assert m.selftest == MobWake.SelfTest + assert %{errors: [], warnings: warnings} = Validator.validate_plugin(m, @plugin_dir) + refute Enum.any?(warnings, &(&1 =~ "selftest")) end test "declares BackgroundTasks + UserNotifications frameworks on iOS", %{manifest: m} do @@ -43,6 +44,78 @@ defmodule MobWakeTest do end end + describe "MobWake.SelfTest" do + defp assert_result(result) do + assert Mob.Plugin.SelfTest.result?(result) + result + end + + test "on a host with no native library linked it fails, naming the NIF, instead of raising" do + for platform <- [:ios, :android] do + assert {:fail, reason} = + assert_result(SelfTest.run(%{platform: platform, device: :simulator})) + + assert reason =~ "mob_wake_nif is not linked" + assert reason =~ "nif_not_loaded" + end + end + + test "iOS passes on every backgroundRefreshStatus the NIF can report" do + for status <- [:available, :denied, :restricted] do + assert :pass == + assert_result(SelfTest.classify(:ios, %{background_refresh_status: status})) + end + end + + test "iOS fails on an unknown status, an empty map or the Android shape" do + for answer <- [ + %{background_refresh_status: :bogus}, + %{}, + %{battery_optimized: false, has_context: true} + ] do + assert {:fail, "platform_signal/0 on ios returned " <> _} = + assert_result(SelfTest.classify(:ios, answer)) + end + end + + test "Android passes once the bridge answered with an app context, whatever the battery setting" do + for optimized <- [true, false] do + assert :pass == + assert_result( + SelfTest.classify(:android, %{battery_optimized: optimized, has_context: true}) + ) + end + end + + test "Android fails when the bridge never got an app context" do + assert {:fail, "MobWakeBridge has no app context" <> _} = + assert_result( + SelfTest.classify(:android, %{battery_optimized: false, has_context: false}) + ) + end + + test "Android fails when the NIF reports the bridge unregistered or no JNIEnv" do + assert {:fail, "Kotlin MobWakeBridge not registered" <> _} = + assert_result(SelfTest.classify(:android, {:error, :bridge_not_registered})) + + assert {:fail, "platform_signal/0 could not get a JNIEnv" <> _} = + assert_result(SelfTest.classify(:android, {:error, :no_jni_env})) + end + + test "Android fails on the pre-fix empty map, the iOS shape or an unexpected answer" do + for answer <- [ + %{}, + %{background_refresh_status: :available}, + %{battery_optimized: :maybe, has_context: true}, + {:error, :map_build_failed}, + :ok + ] do + assert {:fail, "platform_signal/0 on android returned " <> _} = + assert_result(SelfTest.classify(:android, answer)) + end + end + end + describe "MobWake.wake_payload/2 (MOB-269 identifier + payload contract)" do # These tests pin the ADR — decisions/2026-09-18-identifier-and-payload-schema.md. # If any of these fail without a matching ADR update, the receive-side From 98529718d00f7ead5d77edc90edc0b53c16c2840 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 8 Oct 2026 23:47:20 -0600 Subject: [PATCH 2/3] =?UTF-8?q?MOB-418:=20review=20fixes=20=E2=80=94=20nar?= =?UTF-8?q?row=20nif=5Fnot=5Floaded=20catch,=20testable=20status=20signal?= =?UTF-8?q?=20fold,=20erts.errorTuple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mob_wake/self_test.ex | 8 ++++---- lib/mob_wake/wake.ex | 12 ++++++++---- priv/mob_plugin.exs | 2 ++ priv/native/jni/mob_wake_nif.zig | 24 ++++++++++-------------- test/mob_wake/wake_test.exs | 11 +++++++++++ 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/lib/mob_wake/self_test.ex b/lib/mob_wake/self_test.ex index 06a2113..309bb82 100644 --- a/lib/mob_wake/self_test.ex +++ b/lib/mob_wake/self_test.ex @@ -36,9 +36,9 @@ defmodule MobWake.SelfTest do @impl true def run(%{platform: platform}) do classify(platform, :mob_wake_nif.platform_signal()) - rescue - e in ErlangError -> - {:fail, "mob_wake_nif is not linked into this build: #{Exception.message(e)}"} + catch + :error, :nif_not_loaded -> + {:fail, "mob_wake_nif is not linked into this build (nif_not_loaded)"} end @doc false @@ -58,7 +58,7 @@ defmodule MobWake.SelfTest do def classify(:android, {:error, :bridge_not_registered}), do: {:fail, - "Kotlin MobWakeBridge not registered (nativeRegister never ran or a method-ID lookup failed)"} + "Kotlin MobWakeBridge not registered (the plugin bootstrap never called MobWakeBridge.register(), or a method-ID lookup failed)"} def classify(:android, {:error, :no_jni_env}), do: {:fail, "platform_signal/0 could not get a JNIEnv on this thread"} diff --git a/lib/mob_wake/wake.ex b/lib/mob_wake/wake.ex index ef300cd..d71a556 100644 --- a/lib/mob_wake/wake.ex +++ b/lib/mob_wake/wake.ex @@ -233,15 +233,19 @@ defmodule Mob.Wake do # tell right now" — same shape as the platform_signal starting # value. `MobWake.SelfTest` is what turns those answers into failures. defp platform_signal do - case :mob_wake_nif.platform_signal() do - %{} = signal -> signal - {:error, _reason} -> %{} - end + normalize_platform_signal(:mob_wake_nif.platform_signal()) catch :error, :undef -> %{} :error, :nif_not_loaded -> %{} end + @doc false + # The NIF's answer as status/1 reports it: the map as-is, anything else + # (Android's {:error, reason}, an unexpected term) as %{}. + @spec normalize_platform_signal(term()) :: map() + def normalize_platform_signal(%{} = signal), do: signal + def normalize_platform_signal(_other), do: %{} + @doc """ Inventory of all pending fires. diff --git a/priv/mob_plugin.exs b/priv/mob_plugin.exs index 1fcde16..c467850 100644 --- a/priv/mob_plugin.exs +++ b/priv/mob_plugin.exs @@ -1,5 +1,7 @@ %{ name: :mob_wake, + # Coarse manifest range (same as the other mob plugins); the real floor, + # mob >= 0.9.15 for Mob.Plugin.SelfTest, is enforced by mix.exs. mob_version: "~> 0.9", plugin_spec_version: 1, # On-device proof for `mix mob.selftest` / mob_ci: one read-only diff --git a/priv/native/jni/mob_wake_nif.zig b/priv/native/jni/mob_wake_nif.zig index 4d57223..6302d82 100644 --- a/priv/native/jni/mob_wake_nif.zig +++ b/priv/native/jni/mob_wake_nif.zig @@ -405,30 +405,26 @@ fn nif_complete_push_noop(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const ert return erts.ok(env); } -// {error, Reason} for the paths where the NIF is linked but cannot ask the -// bridge. Mob.Wake.status/1 folds these into an empty map ("can't tell right -// now"); MobWake.SelfTest turns them into failures (MOB-418). -fn errorTuple(env: ?*erts.ErlNifEnv, comptime reason: [:0]const u8) erts.ERL_NIF_TERM { - return erts.makeTuple(env, .{ erts.atom(env, "error"), erts.atom(env, reason) }); -} - // platform_signal/0 → %{battery_optimized: bool, has_context: bool} // Wraps MobWakeBridge.platformSignal() which returns a bit-packed int: // bit 0 = batteryOptimized, bit 1 = hasContext. `has_context` distinguishes // "app is whitelisted" from "we can't tell yet" (bridge boot race). // // {error, bridge_not_registered} when MobWakeBridge.register() never ran -// (nativeRegister never cached the class) or the platformSignal method-ID -// lookup failed; {error, no_jni_env} when this thread can't get a JNIEnv. -// These used to return %{} — the same answer as a host with no NIF — so a -// host app whose bootstrap never registered the bridge looked healthy. +// (the plugin bootstrap never registered the bridge) or the platformSignal +// method-ID lookup failed; {error, no_jni_env} when this thread can't get a +// JNIEnv; {error, map_build_failed} if the result map can't be built. These +// used to return %{} — the same answer as a host with no NIF — so a host app +// whose bootstrap never registered the bridge looked healthy. +// Mob.Wake.status/1 folds them into %{} ("can't tell right now"); +// MobWake.SelfTest turns them into failures (MOB-418). fn nif_platform_signal(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const erts.ERL_NIF_TERM) callconv(.c) erts.ERL_NIF_TERM { _ = argc; _ = argv; - if (g_wake_cls == null or g_wake.platform_signal == null) return errorTuple(env, "bridge_not_registered"); + if (g_wake_cls == null or g_wake.platform_signal == null) return erts.errorTuple(env, erts.atom(env, "bridge_not_registered")); var attached: c_int = 0; - const jenv = get_jenv(&attached) orelse return errorTuple(env, "no_jni_env"); + const jenv = get_jenv(&attached) orelse return erts.errorTuple(env, erts.atom(env, "no_jni_env")); const bits = jenv.*.CallStaticIntMethod.?(jenv, g_wake_cls, g_wake.platform_signal); clearPendingJniException(jenv); detachIfAttached(attached); @@ -447,7 +443,7 @@ fn nif_platform_signal(env: ?*erts.ErlNifEnv, argc: c_int, argv: [*]const erts.E const hc_atom = if (has_context) erts.atom(env, "true") else erts.atom(env, "false"); var vals = [_]erts.ERL_NIF_TERM{ bo_atom, hc_atom }; var out: erts.ERL_NIF_TERM = undefined; - if (erts.enif_make_map_from_arrays(env, &keys, &vals, keys.len, &out) == 0) return errorTuple(env, "map_build_failed"); + if (erts.enif_make_map_from_arrays(env, &keys, &vals, keys.len, &out) == 0) return erts.errorTuple(env, erts.atom(env, "map_build_failed")); return out; } diff --git a/test/mob_wake/wake_test.exs b/test/mob_wake/wake_test.exs index 3f934e0..cce1b26 100644 --- a/test/mob_wake/wake_test.exs +++ b/test/mob_wake/wake_test.exs @@ -205,6 +205,17 @@ defmodule Mob.WakeTest do assert s.platform_signal == %{} end + test "reports the NIF's map as-is and folds Android's error answers into %{}" do + ios = %{background_refresh_status: :denied} + android = %{battery_optimized: true, has_context: true} + assert Mob.Wake.normalize_platform_signal(ios) == ios + assert Mob.Wake.normalize_platform_signal(android) == android + + for answer <- [{:error, :bridge_not_registered}, {:error, :no_jni_env}, :unexpected] do + assert Mob.Wake.normalize_platform_signal(answer) == %{} + end + end + test "reflects last_fired_at after a dispatch" do id = unique_tag(:stat_fire) :ok = Mob.Wake.register(id, :refresh, {TestHandlers, :echo_ok}) From e5d4673b8ed95f9928b0708c115cba6d0789deaf Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 8 Oct 2026 23:48:36 -0600 Subject: [PATCH 3/3] CHANGELOG: list {:error, :map_build_failed} --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db4fae9..e672706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S - **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, and - `{:error, :no_jni_env}` when it can't get a JNIEnv, instead of `%{}` (the - same answer as a host with no NIF). `Mob.Wake.status/1` still reports - `platform_signal: %{}` in those cases. + `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