MobDev.UrlSchemes (mob_dev v0.7.17)

Copy Markdown View Source

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.

Summary

Functions

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.

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.

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).

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.

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.

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.

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).

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.

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.

Types

check()

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

One mix mob.doctor row.

Functions

apply_android_manifest!(path, cfg)

@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!(path, cfg, url_name)

@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(cfg, manifest)

@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!(path, cfg, url_name)

@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(manifest, schemes)

@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(manifest, path)

@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(manifest, schemes)

@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(schemes, xml, url_name)

@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(cfg)

@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.