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.
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
offerIdyour Activity needs)
Don't use DeepLinkModule if you just want to:
- Log or observe every inbound intent — use
LifecycleModule.willHandleNewIntentinstead - 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 value | Meaning |
|---|---|
Consumed | Module handled the link. Stop routing. |
DeferUntilAuthenticated | Module owns the URI but needs auth — routing service queues it and re-dispatches after login. Safe to return regardless of current auth state. |
NotHandled | Not 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():
- Pattern match. The service collects every
DeepLinkModulewhosedeepLinkPatternscontains a prefix that matchesuri.path. A module never sees URIs intended for another module. - Iterate matches. Each matching module's
onDeepLinkReceivedis called in registration order. FirstConsumedorDeferUntilAuthenticatedwins. - Default parser fallback. If no module claims the URI, the built-in
DefaultAppLinkParserresolves it to a UUX page id and routes through the WebView. This is theapp://{applicationId}/open?targetPageName=…anduux.aspx#/…flow used by web → native handoff and email links. - Lifecycle observers last. If still unhandled,
LifecycleModule.willHandleNewIntentfires for catch-all observers.
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:
- The filter must be on
MainActivity, notLauncherActivity.LauncherActivityis a splash-only activity that has no ties to the routing service.MainActivityislaunchMode="singleTask"and is whereonNewIntent()→routeIntent()runs. - Use
tools:node="merge"when adding toMainActivity. Core's own manifest already registersMainActivitywith anapp://{applicationId}/openfilter for the default parser. If you declareMainActivityagain withouttools:node="merge", the manifest merger replaces Core's activity block instead of extending it, and Core's default parser will stop working. - 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 letdeepLinkPatternsnarrow to the exact paths your module owns. Do not putandroid:pathPrefixhere unless every partner in the app has coordinated on non-overlapping paths.
<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.jsonrequire a full reinstall (./gradlew :devapp:installDebug) because it's an asset — a hot restart won't re-read it. - Confirm your path matches.
deepLinkPatternsis a prefix match onuri.path. Log the URI at the top ofonDeepLinkReceivedif 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.