# `Mob.Device`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/device.ex#L1)

Cross-platform device events and queries.

`Mob.Device` is the single subscription point for OS-level events that exist
on both iOS and Android. The native side (iOS `NotificationCenter`, Android
the generated `MainActivity`) emits each event as a tagged tuple to a
registered dispatcher pid; this GenServer fans the events out to
subscribers by category.

## Subscribe

    Mob.Device.subscribe()             # default categories
    Mob.Device.subscribe(:all)         # everything
    Mob.Device.subscribe([:app, :power])

Subscribers receive `{:mob_device, atom}` or `{:mob_device, atom, payload}`
in their mailbox. Default categories are `:app`, `:display`, `:audio`, `:memory`.

## Categories and events

- `:app` — `:will_resign_active`, `:did_become_active`, `:did_enter_background`,
  `:will_enter_foreground`, `:will_terminate`

  On Android these come from the app's `MainActivity`: `onResume` →
  `:did_become_active`, `onPause` → `:will_resign_active`, `onStop` →
  `:did_enter_background`, `onStart` after a stop → `:will_enter_foreground`,
  `onDestroy` while finishing → `:will_terminate`. A configuration change
  (rotation) sends nothing. `:will_terminate` is best effort on both
  platforms: an app killed in the background gets no callback. Events
  before the BEAM has started (the first launch's `onResume`) aren't
  delivered. An app whose `MainActivity` predates mob 0.9.6 sends none; see
  the CHANGELOG for the lines to add.
- `:display` — `:screen_off`, `:screen_on`,
  `{:orientation_changed, :portrait | :portrait_upside_down | :landscape_left | :landscape_right}`
- `:audio` — `:audio_interrupted`, `:audio_resumed`, `:audio_route_changed`
- `:appearance` — `{:color_scheme_changed, :light | :dark}`
- `:power` — `{:battery_state_changed, :unplugged | :charging | :full | :unknown}`,
  `{:battery_level_changed, integer}`, `{:low_power_mode_changed, boolean}`
- `:thermal` — `{:thermal_state_changed, :nominal | :fair | :serious | :critical}`
- `:memory` — `:memory_warning`

Platform-specific events with no cross-platform counterpart go through
`Mob.Device.IOS` / `Mob.Device.Android` instead.

## Queries

    Mob.Device.battery_level()      # 0..100 | -1 if unknown
    Mob.Device.battery_state()      # :unplugged | :charging | :full | :unknown
    Mob.Device.thermal_state()      # :nominal | :fair | :serious | :critical
    Mob.Device.low_power_mode?()    # boolean
    Mob.Device.foreground?()        # boolean
    Mob.Device.os_version()         # binary
    Mob.Device.app_version()        # binary | nil
    Mob.Device.model()              # binary
    Mob.Device.orientation()        # :portrait | :landscape_left | ...

## Orientation

`orientation/0` reports the current interface orientation; subscribe to
`:display` to get `{:mob_device, :orientation_changed, orientation}` when the
device rotates.

    Mob.Device.orientation()                 # :portrait
    Mob.Device.lock_orientation(:landscape)  # force landscape (either side)
    Mob.Device.unlock_orientation()          # follow the sensor again

Locking forces the orientation regardless of the OS auto-rotate-lock setting
(Android `setRequestedOrientation`; iOS supported-orientations + a geometry
request). It is global to the app — a screen that wants to be landscape-only
should `lock_orientation/1` on enter and `unlock_orientation/0` on leave.

# `category`

```elixir
@type category() ::
  :app
  | :display
  | :audio
  | :appearance
  | :power
  | :thermal
  | :memory
  | :network
```

# `event`

```elixir
@type event() :: atom()
```

# `lock`

```elixir
@type lock() ::
  :portrait
  | :portrait_upside_down
  | :landscape
  | :landscape_left
  | :landscape_right
```

A lock request. `:landscape` / `:portrait` allow either side of that axis;
the four concrete values pin a single orientation.

# `network_state`

```elixir
@type network_state() :: %{
  online: boolean(),
  transport: transport(),
  expensive: boolean(),
  validated: boolean() | unavailable(),
  constrained: boolean() | unavailable()
}
```

A connectivity snapshot. `online`/`transport`/`expensive` are cross-platform.
`validated` and `constrained` are each exposed by only one platform; the other
reports `:unavailable` rather than a misleading `false`.

# `orientation`

```elixir
@type orientation() ::
  :portrait
  | :portrait_upside_down
  | :landscape_left
  | :landscape_right
  | :unknown
```

A concrete interface orientation.

# `transport`

```elixir
@type transport() :: :wifi | :cellular | :wired | :other | :none
```

The transport currently carrying the network path.

# `unavailable`

```elixir
@type unavailable() :: :unavailable
```

A per-platform signal that this platform does not expose — distinct from a
known `false`. e.g. `validated` on iOS, `constrained` on Android.

# `app_version`

```elixir
@spec app_version() :: String.t() | nil
```

The app's store-visible version string, read from the running binary:
`CFBundleShortVersionString` on iOS, `PackageInfo.versionName` on Android.
Returns `nil` when the platform can't report it.

Prefer this over a compile-time config value for update gates — config can
drift from the version the store actually shipped.

    Mob.Device.app_version()
    #=> "1.4.0"

# `battery_level`

```elixir
@spec battery_level() :: integer()
```

Current battery level (0..100), or -1 if unknown.

# `battery_state`

```elixir
@spec battery_state() :: :unplugged | :charging | :full | :unknown
```

Current battery state — `:unplugged | :charging | :full | :unknown`.

# `categories`

```elixir
@spec categories() :: [category()]
```

Returns the configured list of valid categories.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `foreground?`

```elixir
@spec foreground?() :: boolean()
```

True if the app is currently in the foreground and active: iOS
`applicationState == .active`; on Android, from `:did_become_active` until
`:will_resign_active`. On Android it stays `true` in an app whose
`MainActivity` doesn't send lifecycle events (generated before mob 0.9.6).

# `keep_awake`

```elixir
@spec keep_awake(boolean()) :: :ok
```

Keep the screen awake — disable the auto-dim / auto-lock idle timer while
`on?` is `true`, release it with `false`. Useful for video, reading,
navigation, or any screen the user watches without touching. No permission
required on either platform.

The keep-awake flag is app-scoped and the OS clears it when the app is
backgrounded, so re-assert it on resume (e.g. from the `:app`
`did_become_active` event) if you need it to persist across a background/
foreground cycle. On a device with no active window (e.g. before first
render) it's a no-op.

iOS: `UIApplication.isIdleTimerDisabled`. Android: the window's
`FLAG_KEEP_SCREEN_ON`.

# `lock_orientation`

```elixir
@spec lock_orientation(lock()) :: :ok | {:error, :invalid}
```

Lock the app to `orientation`, overriding the OS auto-rotate setting.

Accepts `:portrait`, `:portrait_upside_down`, `:landscape` (either side),
`:landscape_left`, or `:landscape_right`. Returns `{:error, :invalid}` for
anything else. Global to the app; pair with `unlock_orientation/0`.

# `low_power_mode?`

```elixir
@spec low_power_mode?() :: boolean()
```

True if Low Power Mode (iOS) / Power Save Mode (Android) is on.

# `model`

```elixir
@spec model() :: String.t()
```

Device model (e.g. "iPhone", "Pixel 8").

# `network_state`

```elixir
@spec network_state() :: network_state()
```

Current network connectivity snapshot.

`online` is true when the OS reports a usable default network *path* is up.
It does **not** guarantee the internet is reachable — a captive-portal or
not-yet-validated network reports `online: true` on both platforms (iOS
`NWPath` satisfied / Android default `NetworkCallback`). `transport` names
the active interface — `:wifi | :cellular | :wired | :other | :none`; it is
`:none` exactly when `online` is false. `expensive` is true on metered links
(cellular, personal hotspot), so defer large transfers when it is set.

`validated` and `constrained` are single-platform signals — each platform
reports `:unavailable` (not a misleading `false`) for the one it can't answer:

* `validated` — **Android only** (`NET_CAPABILITY_VALIDATED`): the OS actively
  probed and confirmed real internet reachability; `false` on a captive portal
  or before validation completes. `:unavailable` on iOS (NWPath has no probe).
* `constrained` — **iOS only** (`nw_path_is_constrained`): Low Data Mode is on.
  `:unavailable` on Android (no per-network equivalent).

    # iOS
    #=> %{online: true, transport: :wifi, expensive: false,
    #     validated: :unavailable, constrained: false}
    # Android
    #=> %{online: true, transport: :wifi, expensive: false,
    #     validated: true, constrained: :unavailable}

Subscribe to the `:network` category to receive
`{:mob_device, :connectivity_changed, state}` when this changes — `state` is
the same full map shape returned here (all five keys, `validated`/`constrained`
included).

# `online?`

```elixir
@spec online?() :: boolean()
```

True if the device currently has a usable network path.

# `open_settings`

```elixir
@spec open_settings(:app | :notifications | :exact_alarm) :: :ok | {:error, :invalid}
```

Opens an OS settings screen for this app.

`target` is one of:

  * `:app` — the app's details / permissions page (both platforms). The
    go-to when a runtime permission was *permanently* denied and the user
    must re-enable it by hand.
  * `:notifications` — the app's notification settings (Android). iOS has no
    granular deep-link, so it opens the app page.
  * `:exact_alarm` — the "Alarms & reminders" special-access screen
    (Android 12+). iOS opens the app page.

iOS exposes only the single app settings page, so `target` is honored on
Android and falls back to the app page on iOS. Fire-and-forget: a valid target
returns `:ok` immediately (an unavailable screen is a no-op, not a raise); an
unknown target returns `{:error, :invalid}` without touching the NIF.

# `open_url`

```elixir
@spec open_url(String.t()) :: :ok
```

Hands a URL to the OS to open in the default browser/handler.

Fire-and-forget. Returns `:ok` immediately; failures (malformed URL, no
registered handler) are logged but not raised.

# `orientation`

```elixir
@spec orientation() :: orientation()
```

Current interface orientation — `:portrait | :portrait_upside_down |
:landscape_left | :landscape_right`, or `:unknown` if it can't be determined.

# `os_version`

```elixir
@spec os_version() :: String.t()
```

OS version string (e.g. "17.4").

# `start_link`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

Start the dispatcher. Called from `Mob.Application` (or the app supervision
tree). After start, the registered NIF dispatcher pid is this GenServer.

# `subscribe`

```elixir
@spec subscribe(category() | [category()] | :all) :: :ok
```

Subscribe the calling process to device events.

Accepts a single category atom, a list of categories, or `:all`.
Default is `[:app, :display, :audio, :appearance, :memory]`.

    Mob.Device.subscribe()
    Mob.Device.subscribe(:thermal)
    Mob.Device.subscribe([:app, :power, :thermal])
    Mob.Device.subscribe(:all)

# `thermal_state`

```elixir
@spec thermal_state() :: :nominal | :fair | :serious | :critical
```

Current thermal state — `:nominal | :fair | :serious | :critical`.

# `unlock_orientation`

```elixir
@spec unlock_orientation() :: :ok
```

Release an orientation lock; the app follows the sensor again.

# `unsubscribe`

```elixir
@spec unsubscribe() :: :ok
```

Unsubscribe the calling process from all categories.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
