Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ 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,
`{: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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
73 changes: 73 additions & 0 deletions lib/mob_wake/self_test.ex
Original file line number Diff line number Diff line change
@@ -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())
catch
:error, :nif_not_loaded ->
{:fail, "mob_wake_nif is not linked into this build (nif_not_loaded)"}
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 (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"}

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
15 changes: 12 additions & 3 deletions lib/mob_wake/wake.ex
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,25 @@ 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()
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.

Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading
Loading