Skip to main content

Android - Deep Link Module Interface

The DeepLinkModule interface lets a module claim URI-based deep links — custom-scheme links like app://{applicationId}/... and verified https:// app links. The routing service pattern-matches inbound URIs against each module's declared deepLinkPatterns so a module never sees URIs intended for another module.

Push notifications are not this interface — see NotificationModule. URI intents and FCM payloads are routed separately.

Available since

DeepLinkModule and DeepLinkResult were added in MSDK Android 26.7.0 as part of the sdk_interfaces 0.4.0 bundle.

DevApp Setup

No additional dependencies are required. Core wires MainActivity.onNewIntent() to the deep-link routing service automatically — as long as the module is registered in settings.json, warm-start ACTION_VIEW intents flow through to onDeepLinkReceived.

Your module still needs an <intent-filter> in the DevApp's AndroidManifest.xml for Android to deliver a matching URI to MainActivity in the first place — see Manifest Declaration below for the specific shape it must take.

When To Use It

Most deep links do not need a DeepLinkModule. If the inbound URI is already correctly formed (the standard app://{applicationId}/open?targetPageName=… and UUX #/… shapes), Core's default parser navigates the WebView for you without any module involvement.

Add a DeepLinkModule only when you need to do something the default parser cannot.

Use DeepLinkModule when you need to:

  • Redirect the user somewhere different than the URL implies
  • Launch a native Activity, dialog, or partner-specific flow
  • Defer a link until after the user authenticates
  • Read data from the URI and pass it to your module's own logic (e.g. an offerId your Activity needs)

Don't use DeepLinkModule if you just want to:

  • Log or observe every inbound intent — use LifecycleModule.willHandleNewIntent instead
  • Handle a URL that already routes correctly through the default parser — leave it alone

Implementation

Create a class and inherit DeepLinkModule. Declare the URI path prefixes you own and return a DeepLinkResult from onDeepLinkReceived.

class RewardsDeepLinkModule(private val sdkUtils: SdkUtils) : DeepLinkModule {

override val deepLinkPatterns = listOf("/modules/rewards")

override fun onDeepLinkReceived(uri: Uri, isUserAuthenticated: Boolean): DeepLinkResult {
if (!isUserAuthenticated) {
// Routing service queues the URI; we get it back after login.
return DeepLinkResult.DeferUntilAuthenticated
}

// Do whatever your module needs — read query params, launch an Activity,
// navigate UUX yourself, etc.
val offerId = uri.getQueryParameter("offerId")
sdkUtils.loadPathInUuxViewAfterLogon("extension/Rewards?offerId=$offerId")

return DeepLinkResult.Consumed
}
}

Interface Reference

interface DeepLinkModule : SDKModule {

/**
* URI path prefixes this module handles. Each pattern is matched as a path
* prefix against the inbound URI's path; scheme and host are validated by the
* routing service separately and must not be included.
*
* Patterns must begin with `/`. An empty list disables matching. URIs with no
* path (e.g. `app://example?foo=bar`) will not match any pattern.
*/
val deepLinkPatterns: List<String>

/**
* Called when an inbound URI matches one of [deepLinkPatterns]. The full URI —
* including query and fragment — is provided.
*/
@MainThread
fun onDeepLinkReceived(uri: Uri, isUserAuthenticated: Boolean): DeepLinkResult
}

DeepLinkResult

sealed class DeepLinkResult {
object Consumed : DeepLinkResult()
object DeferUntilAuthenticated : DeepLinkResult()
object NotHandled : DeepLinkResult()
}
Return valueMeaning
ConsumedModule handled the link. Stop routing.
DeferUntilAuthenticatedModule owns the URI but needs auth — routing service queues it and re-dispatches after login. Safe to return regardless of current auth state.
NotHandledNot mine — try the next matching module, then the default parser. Must be side-effect free.

Dispatch Model

Core's deep-link routing service handles every Intent.ACTION_VIEW with a Uri that arrives at MainActivity.onNewIntent():

  1. Pattern match. The service collects every DeepLinkModule whose deepLinkPatterns contains a prefix that matches uri.path. A module never sees URIs intended for another module.
  2. Iterate matches. Each matching module's onDeepLinkReceived is called in registration order. First Consumed or DeferUntilAuthenticated wins.
  3. Default parser fallback. If no module claims the URI, the built-in DefaultAppLinkParser resolves it to a UUX page id and routes through the WebView. This is the app://{applicationId}/open?targetPageName=… and uux.aspx#/… flow used by web → native handoff and email links.
  4. Lifecycle observers last. If still unhandled, LifecycleModule.willHandleNewIntent fires for catch-all observers.
Cold-start intents are not routed

DeepLinkRoutingService.routeIntent() is only wired into MainActivity.onNewIntent()not onCreate(). A URI that launches the app from a killed state reaches Core through the normal launch flow and does not fire onDeepLinkReceived. Only warm-start intents (the app was already in the foreground or in the recents stack) reach a DeepLinkModule.

If your module needs to react to cold-start deep links, catch the launch intent from LifecycleModule.onCreate() yourself and defer routing until the user is authenticated.

Patterns

deepLinkPatterns are path prefixes — scheme and host are validated by the routing service and must not appear in patterns. Patterns must begin with /.

val deepLinkPatterns = listOf("/modules/rewards")
// matches app://{appId}/modules/rewards
// matches app://{appId}/modules/rewards/redeem?offerId=abc
// matches https://verified.example.com/modules/rewards (if app link verification set up)

URIs with a null path (e.g. app://example?foo=bar) match no module.

Auth-Deferred Dispatch

If a link arrives before the user is authenticated, return DeepLinkResult.DeferUntilAuthenticated. The routing service queues the URI and re-dispatches it to the same module once the user reaches the post-login landing page.

DeferUntilAuthenticated is safe to return regardless of the current auth state — the routing service decides whether to dispatch immediately or queue. Modules do not need to track auth state themselves.

Manifest Declaration Still Required

This interface only handles in-app routing. Android won't deliver URIs to MainActivity unless an <intent-filter> for the scheme/host is declared in the DevApp's AndroidManifest.xml. There are three specifics that are easy to get wrong:

  1. The filter must be on MainActivity, not LauncherActivity. LauncherActivity is a splash-only activity that has no ties to the routing service. MainActivity is launchMode="singleTask" and is where onNewIntent()routeIntent() runs.
  2. Use tools:node="merge" when adding to MainActivity. Core's own manifest already registers MainActivity with an app://{applicationId}/open filter for the default parser. If you declare MainActivity again without tools:node="merge", the manifest merger replaces Core's activity block instead of extending it, and Core's default parser will stop working.
  3. The scheme/host filter here is coarse. Path filtering is your module's job. Filter on scheme (app) and host (${applicationId}) at the manifest layer, then let deepLinkPatterns narrow to the exact paths your module owns. Do not put android:pathPrefix here unless every partner in the app has coordinated on non-overlapping paths.
devapp/src/main/AndroidManifest.xml
<application ...>
<!--
Adds a second VIEW filter to Core's MainActivity so any app://{applicationId}
URI reaches onNewIntent → DeepLinkRoutingService → your module. Core keeps
its own app://{applicationId}/open filter for the default parser.
-->
<activity
android:name="com.q2.app.core.ui.MainActivity"
tools:node="merge"
android:exported="true">
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="app" android:host="${applicationId}" />
</intent-filter>
</activity>
</application>

For verified app links (https://verified.example.com/...) the shape is the same but with android:autoVerify="true" and your verified host:

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="verified.example.com" />
</intent-filter>

Remember to declare xmlns:tools="http://schemas.android.com/tools" on the <manifest> root if you're using tools:node.

Testing With adb

Once the filter is in place and your module is registered in settings.json with "enabled": true, you can trigger it from a terminal:

# 1. Bring the DevApp to the foreground so the intent hits onNewIntent (not onCreate).
adb shell am start -n com.q2.devapp/com.q2.app.core.ui.LauncherActivity

# 2. Fire the deep link. Single-quote the URI — zsh treats '?' as a glob.
adb shell am start -W -a android.intent.action.VIEW \
-d 'app://com.q2.devapp/modules/example?target=messages'

A successful hit reports LaunchState: HOT or WARM (not COLD) and your breakpoint in onDeepLinkReceived fires. If you see LaunchState: COLD, the app was killed — Core does not route cold-start intents (see Dispatch Model).

If nothing fires:

  • Confirm the filter registered. adb shell dumpsys package com.q2.devapp | grep -A4 'Scheme:' should list your scheme/host.
  • Confirm the module was picked up. Changes to settings.json require a full reinstall (./gradlew :devapp:installDebug) because it's an asset — a hot restart won't re-read it.
  • Confirm your path matches. deepLinkPatterns is a prefix match on uri.path. Log the URI at the top of onDeepLinkReceived if you're unsure whether it's being filtered out.

Threading

onDeepLinkReceived runs on the main thread. Don't block — hand off long-running work to coroutines or WorkManager.

Registration

DeepLinkModule implementations are registered through the standard SDK module registration in settings.json (sdk_modules list). The routing service pulls them via SdkModuleStore.getDeepLinkModuleList() — no extra wiring needed.

Learn more in Configuring settings.json.

Migrating from LifecycleModule.willHandleNewIntent

If your module used LifecycleModule.willHandleNewIntent for pattern-scoped deep-link routing (e.g. "if the URI path starts with /modules/rewards, open my module"), migrate to DeepLinkModule — it gives pattern matching, auth-deferred queueing, and result-typed dispatch for free. Continue using LifecycleModule.willHandleNewIntent for catch-all observation of every inbound intent.