# `MobDev.UrlSchemes`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/url_schemes.ex#L1)

Custom URL schemes that open the app (MOB-379): `config :mob_dev,
url_schemes: ["myapp"]` in `mob.exs`. mob delivers the URL to the BEAM as
`{:link, %{url: url, source: :launch | :running}}`; this module only makes the
OS route the URL to the app.

The build stamps the setting into native files every time, so a changed or
removed setting takes effect on the next native build of new and existing
apps alike:

  * Android — a managed `<intent-filter>` (`android.intent.action.VIEW`,
    categories `DEFAULT` + `BROWSABLE`, one `<data android:scheme>` per
    scheme) inside the launcher activity of
    `android/app/src/main/AndroidManifest.xml`, fenced by
    `mob:url-schemes` markers (`MobDev.Plugin.ManagedBlock`). The dev build
    (`mix mob.deploy --native`) and `mix mob.release --android` regenerate it;
    unset, `nil` or `[]` removes it.
  * iOS — a `CFBundleURLTypes` entry (`CFBundleURLName` = the iOS bundle id,
    `CFBundleTypeRole` `Viewer`) appended to the built bundle's Info.plist by
    the simulator and device builds and `mix mob.release --ios`.
    `ios/Info.plist` is never rewritten.

A scheme the project already routes to the app is left to it. On Android
that is a `VIEW` + `DEFAULT` + `BROWSABLE` intent filter on the launcher
activity, outside the managed block, declaring the scheme with no host,
path or other narrowing. A scheme in a `VIEW` filter on another activity is
still added to the launcher, with a build warning: Android may then ask
which activity opens the link. On iOS it is a scheme in any existing
`CFBundleURLTypes` entry, compared case-insensitively as iOS does. The app's
own entries are never changed.

Schemes must be lowercase RFC 3986 schemes. `http` and `https` are refused:
verified App Links and universal links need a host and domain verification,
which a bare scheme can't express.

The launcher activity must be `android:launchMode="singleTask"` (or
`singleInstance`) when `url_schemes` is set; the build refuses anything
else rather than rewrite `launchMode`. Otherwise a link opened from another
app's task starts a second `MainActivity` there, and two activities drive
one BEAM. mob_new's template uses `singleTop`, since `singleTask` also
finishes the activities stacked above `MainActivity` whenever the app is
reopened from its icon, so an app that takes deep links opts in.

See `decisions/2026-10-03-url-schemes.md`.

# `check`

```elixir
@type check() :: {:ok | :fail, String.t(), String.t(), String.t() | nil}
```

One `mix mob.doctor` row.

# `apply_android_manifest!`

```elixir
@spec apply_android_manifest!(
  Path.t(),
  keyword()
) :: :ok
```

Applies `merge_manifest/2` with `mob.exs`'s schemes to the manifest at
`path`, writing only when it changed. With `url_schemes` unset the merge
still runs, which removes a block an earlier build added. Raises `Mix.Error`
on an invalid setting, when schemes are set and `path` doesn't exist, or
when schemes are set and `launch_mode_error/2` objects to the launcher
activity.

# `apply_plist!`

```elixir
@spec apply_plist!(Path.t(), keyword(), String.t()) :: :ok
```

Adds `mob.exs`'s schemes to the Info.plist at `path` with PlistBuddy
(macOS), under `CFBundleURLName` `url_name`. Leaves the file untouched when
`url_schemes` is unset or every scheme is already declared. Raises when any
command fails.

# `audit`

```elixir
@spec audit(
  keyword(),
  String.t() | nil
) :: [check()]
```

The `mix mob.doctor` row for `mob.exs`'s `url_schemes` and the text of the
project's `AndroidManifest.xml` (`nil` when there is none): none when
`url_schemes` is unset or empty, `:ok` listing the schemes, or `:fail` on a
value the build refuses or a launcher activity it refuses
(`launch_mode_error/2`).

# `bundle_plist_commands!`

```elixir
@spec bundle_plist_commands!(Path.t(), keyword(), String.t()) :: [String.t()]
```

`plist_commands/3` for `mob.exs`'s schemes and the Info.plist at `path` (any
plist format; read with macOS `plutil`). `[]` without reading the file when
`url_schemes` is unset or empty. Raises `Mix.Error` on an invalid setting.

# `declared_elsewhere`

```elixir
@spec declared_elsewhere(String.t(), [String.t()]) :: [String.t()]
```

The schemes `merge_manifest/2` adds that a `VIEW` intent filter on another
activity also declares. Android may then ask the user which activity opens
the link, so the build warns about each.

# `launch_mode_error`

```elixir
@spec launch_mode_error(String.t(), Path.t()) :: String.t() | nil
```

Why the launcher activity in `manifest` (the text of the file at `path`)
can't take deep links, or `nil` when it can: its `android:launchMode` must
be `singleTask` or `singleInstance`. For an `<activity-alias>` launcher
that is the launch mode of its `android:targetActivity`, matched against
`<activity android:name>` as Android resolves names (`.X`, `X` and
`<package>.X` name the same activity; an exact spelling wins). A manifest
with no launcher activity, or an alias with no or an undeclared target, is
an error too.

# `merge_manifest`

```elixir
@spec merge_manifest(String.t(), [String.t()]) :: String.t()
```

Regenerates the managed deep-link `<intent-filter>` inside the launcher
activity (the one with a `MAIN` + `LAUNCHER` intent filter; the first, if
several), just before its `</activity>`. A scheme the launcher activity
already routes in full outside the managed block is skipped: one in a
`VIEW` filter with the `DEFAULT` and `BROWSABLE` categories whose `<data>`
elements set no `android:` attribute besides `android:scheme`. A filter
narrowed by a host, port, path, ssp or MIME type, one missing a category,
or one on another activity doesn't count. With nothing left to declare the
block is removed.
Idempotent. Raises `Mix.Error` when `schemes` is non-empty and there is no
launcher activity, or its `</activity>` shares a line with other markup (the
managed block occupies whole lines).

# `plist_commands`

```elixir
@spec plist_commands([String.t()], String.t(), String.t()) :: [String.t()]
```

The PlistBuddy commands that add `schemes` to an Info.plist whose XML text
is `xml`: one new `CFBundleURLTypes` entry named `url_name`, role `Viewer`
(Apple requires `CFBundleTypeRole` in each entry), appended after the
plist's own entries (creating the array when absent), holding the schemes
the plist doesn't already declare. `[]` when there is nothing to
add. Every command must succeed. Raises `Mix.Error` when `xml` isn't an XML
property list or its `CFBundleURLTypes` isn't an array.

# `schemes`

```elixir
@spec schemes(keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
```

Validates `mob.exs` `url_schemes`. Unset, `nil` (read as unset, like
`MobDev.IosLayoutPlist`'s keys) and `[]` are `{:ok, []}` (no deep-link
scheme); duplicates are dropped, order kept.

---

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