Handlers
On this page
Introduction#
Handlers are the core processing unit of the deep link pipeline. When a deep link arrives, the DeeplinkManager walks its registered handler list and delegates the URI to the first handler that claims it. This chain-of-responsibility pattern keeps each handler focused on a single concern and makes the pipeline trivially extensible.
Every handler implements the DeeplinkHandler contract. The two built-in handlers, RouteDeeplinkHandler and OneSignalDeeplinkHandler, cover the most common integration scenarios out of the box.
The DeeplinkHandler Contract#
All handlers extend the abstract DeeplinkHandler class:
abstract class DeeplinkHandler {
bool canHandle(Uri uri);
Future<bool> handle(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
});
}
canHandle#
bool canHandle(Uri uri);
A synchronous predicate. Return true if this handler wants to process the URI; false to pass it to the next handler in the chain. Keep this method cheap: it runs on every incoming link before any handler is invoked.
handle#
Future<bool> handle(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
});
The async processing method, called only when canHandle returned true. Return true if the URI was successfully handled, false if processing failed but should not be retried by another handler. Never throw from this method: exceptions break the pipeline. Catch internally and return false on failure.
source and payload travel with the URI all the way from DeeplinkManager.handleUri(); see DeeplinkSource below for what they carry and why a handler should check them before trusting anything beyond the path.
DeeplinkSource#
enum DeeplinkSource { osLink, push, manual }
Where the instruction to open uri came from, and the two paths are not equally trusted:
| Value | Meaning | payload |
|---|---|---|
osLink |
The operating system opened the app on a Universal Link or App Link. Attacker-craftable: anyone who can get the device to open a URI can produce one of these. | Always null. |
push |
The user tapped a push notification, and the payload is the server's own. | The full push payload the notification arrived with. |
manual |
The application asked for the link itself, in code or in a test. | Whatever the caller passed, or null. |
source is a required, non-defaulted parameter precisely so a handler cannot forget to ask: a handler that reads payload to act on more than the path it was given (switching the user's active team off a team_id key, say) may only do that when source == DeeplinkSource.push, because that is the one case where the payload was authored by the server rather than crafted into a URI. RouteDeeplinkHandler reads them only when it was given a tenantGate: navigating to a path the consumer listed is safe regardless of who asked for it, moving the session's tenant is not.
Built-in Handlers#
RouteDeeplinkHandler#
Matches incoming URI paths against a list of patterns and navigates to the matched route using MagicRoute.to().
Constructor:
RouteDeeplinkHandler({
required List<String> paths,
List<String>? hosts,
bool caseSensitive = false,
TenantSwitchGate? tenantGate,
})
hosts is optional and defaults to null, which keeps the original path-only behaviour: an absolute URI on any host matching one of paths is claimed. Set it to gate canHandle on the address as well:
- A relative URI (no scheme, no authority, the shape a push payload path carries) is always accepted; there is no host to check.
- An absolute URI is accepted only over
httporhttps, with no explicit port and no userinfo, and only when its host equals one entry ofhostscase-insensitively. Anything else (a different host, a non-http(s) scheme, a port, embedded credentials) is refused before the path is even matched. - A blank entry in
hostsis ignored rather than treated as a wildcard.hosts: ['']refuses every absolute URI, including one whose own host is empty (https:/incidents/5); it does not fall back to matching any host.
final handler = RouteDeeplinkHandler(
paths: ['/monitors/:id'],
hosts: ['example.com'],
);
handler.canHandle(Uri.parse('/monitors/5')); // true, relative
handler.canHandle(Uri.parse('https://EXAMPLE.com/monitors/5')); // true, case-insensitive
handler.canHandle(Uri.parse('https://evil.com/monitors/5')); // false, wrong host
handler.canHandle(Uri.parse('https://example.com:8443/monitors/5')); // false, explicit port
caseSensitive is optional and defaults to false, matching go_router's own default of case-sensitive routes only when a consumer opts in: /incidents/5 and /INCIDENTS/5 both match a paths entry of /incidents/:id unless caseSensitive: true is passed, in which case only the exact case matches and the mismatched one falls through to the next handler (or to go_router's not-found page if none claims it).
Pattern syntax:
| Pattern | Matches |
|---|---|
/products |
Exactly /products |
/products/* |
Any single path segment after /products/ |
/products/:id |
A single non-slash segment (named parameter) |
/shop/*/detail |
Any path with a wildcard middle segment |
Matching is case-insensitive by default (see caseSensitive above) and trailing slashes are normalized before comparison.
Example:
final handler = RouteDeeplinkHandler(
paths: [
'/products/:id',
'/shop/*',
'/promotions',
],
);
When a matching URI arrives, the handler calls:
MagicRoute.to(uri.path, query: uri.queryParameters);
Query parameters are forwarded automatically, so https://example.com/products/42?ref=email navigates to /products/42 with {'ref': 'email'} available via Request.query('ref'). The path navigated is the PARSED uri.path, the same value canHandle matched, because Uri resolves dot segments while parsing.
handle never throws. It re-checks canHandle and answers false for a path it does not serve, and a navigation that fails (a router that is not built yet answers with a StateError) is logged through magic's Log, when a log binding exists, and answered false.
Switching Tenants from a Push#
A multi-tenant backend resolves a page against the session's current tenant and answers 404 for another tenant's row. A push paging a user about another team's incident would land on that 404, so RouteDeeplinkHandler can switch first when it is given a TenantSwitchGate:
final handler = RouteDeeplinkHandler(
paths: ['/incidents/:id'],
tenantGate: TenantSwitchGate(
// Your app's own team state; any `String?` and any `Future<bool>` will do.
currentTenantId: () => TeamController.instance.currentTeamId?.toString(),
switchTenant: (String teamId) => TeamController.instance.switchTeam(teamId),
onSwitched: () => Magic.success('Switched team', 'Opened on the team it belongs to.'),
onSwitchFailed: (String teamId) => Magic.error('Error', 'Could not switch team.'),
),
);
| Field | Meaning |
|---|---|
payloadKey |
The push payload key naming the owning tenant. Defaults to 'team_id'. |
currentTenantId |
The session's current tenant id, or null while it has not resolved. |
switchTenant |
Moves the session onto a tenant and answers whether it took. |
onSwitched |
Optional. Runs after a successful switch, before the navigation. One that throws is logged and the link still opens, since the session has already moved. |
onSwitchFailed |
Optional. Runs with the requested tenant id when switchTenant answered false or threw. |
The rules are the handler's and no gate configuration changes them:
- Only
DeeplinkSource.pushswitches. AnosLinkor amanuallink navigates without switching, whatever its payload or query carries: an OS link is attacker-craftable, and a switch moves the session's scoping (and often its billing) subject. - The tenant comes from the payload under
payloadKey, never from the URI query. The query is part of the link. - Ids compare as trimmed strings, so a wire
5and a local'5'are the same tenant. - No evidence, no switch. An absent or blank payload tenant, or a
nullcurrent tenant, navigates directly. - A failed switch does not navigate.
switchTenantansweringfalsecallsonSwitchFailed, logs an error and answersfalse; opening the page anyway would meet the same 404. AswitchTenantthat throws counts as a failed switch: it callsonSwitchFailed, is logged, and answersfalsewithout navigating. AnonSwitchedthat throws is logged and the page still opens, because by then the session is already on the new tenant.
Breadcrumb Events#
RouteDeeplinkHandler dispatches two magic events through Event.dispatch, unawaited, so a failing listener never costs the navigation:
| Event | When | breadcrumbCategory |
breadcrumbData |
|---|---|---|---|
DeeplinkOpened |
After canHandle passed, before any switch or navigation |
deeplink.open |
source, route, names_tenant |
DeeplinkNavigating |
Immediately before MagicRoute.to |
deeplink.navigate |
route |
Both implement magic's ReportsBreadcrumb, so a crash reporter that watches every event with Event.listenAny (as magic_sentry does) records them without this package depending on it. Dispatching DeeplinkNavigating right before the navigation makes it the last breadcrumb on an exception thrown while the destination builds, which tells "the link never resolved" apart from "the page it named threw". route is the handler's own pattern that matched (/invitations/:token/accept), not the concrete path, and the data never carries the query string or a payload value, since a link can carry an invite or reset token in any of them.
Event.listenAny((MagicEvent event) {
if (event is DeeplinkNavigating) {
debugPrint('opening a link matching ${event.route}');
}
});
OneSignalDeeplinkHandler#
Bridges OneSignal push-notification click events into the deep link pipeline. It does not implement DeeplinkHandler directly: instead it acts as a listener adapter that extracts a URI from the notification payload and feeds it to the manager.
URI extraction:
The handler checks the notification data map for these keys in order: url, deep_link, link, uri. The first non-empty string value that parses as a valid URI is used.
Setup:
void setup(DeeplinkManager manager, dynamic notifications)
Call setup() once, passing the manager singleton and the magic_notifications notification manager (resolved out of the container, e.g. app.make('notifications')). notifications is read structurally as dynamic so this package declares no dependency on magic_notifications. The handler subscribes to the manager's own onPushClicked stream, which the manager owns from construction and republishes onto whenever a driver is attached later, so it works whether the notifications provider has booted yet or not. Every matched URI is routed through manager.handleUri().
A notification manager that publishes no onPushClicked stream, or an event carrying no readable data payload, is reported at error level through magic's Log (guarded by Magic.bound('log')) rather than thrown or silently dropped.
Disposal:
void dispose()
Cancels the internal stream subscription. Call this in your service provider's teardown or when the handler is no longer needed.
Example:
final onesignalHandler = OneSignalDeeplinkHandler();
// Inside DeeplinkServiceProvider.boot(), after app.bound('notifications'):
onesignalHandler.setup(
DeeplinkManager(),
app.make('notifications'), // the magic_notifications manager
);
Creating Custom Handlers#
Extend DeeplinkHandler to handle any URI matching logic your application requires.
import 'package:magic_deeplink/magic_deeplink.dart';
class ProductDeeplinkHandler extends DeeplinkHandler {
@override
bool canHandle(Uri uri) {
// Only claim URIs with the /products path and a numeric id segment
final segments = uri.pathSegments;
return segments.length == 2 &&
segments[0] == 'products' &&
int.tryParse(segments[1]) != null;
}
@override
Future<bool> handle(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
}) async {
final id = int.parse(uri.pathSegments[1]);
try {
final product = await ProductRepository.find(id);
if (product == null) {
MagicRoute.to('/products/not-found');
return false;
}
MagicRoute.to('/products/$id', query: uri.queryParameters);
return true;
} catch (_) {
return false;
}
}
}
Key rules when implementing a custom handler:
canHandlemust be synchronous and side-effect free.handlemust never throw: wrap async operations in try-catch.- Return
false(not throw) when the URI cannot be handled aftercanHandlereturnedtrue. - Trust
payloadonly whensource == DeeplinkSource.push; anosLinkcarries no payload and its URI is attacker-craftable.
Registering Handlers#
Register handlers on the DeeplinkManager singleton via registerHandler(). The manager deduplicates registrations: adding the same instance twice has no effect.
final manager = DeeplinkManager();
manager.registerHandler(
RouteDeeplinkHandler(paths: ['/products/:id', '/shop/*']),
);
manager.registerHandler(ProductDeeplinkHandler());
The recommended place to register handlers is inside DeeplinkServiceProvider.boot(), after the driver is initialized:
class DeeplinkServiceProvider extends ServiceProvider {
@override
Future<void> boot() async {
final manager = app.make<DeeplinkManager>('deeplinks');
manager.registerHandler(
RouteDeeplinkHandler(
paths: app.make<ConfigRepository>('config').get('deeplink.paths'),
),
);
manager.registerHandler(ProductDeeplinkHandler());
}
}
Handler Execution Order#
Handlers are evaluated in registration order. The manager iterates the list and stops at the first handler whose canHandle returns true:
Future<bool> handleUri(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
}) async {
for (final handler in _handlers) {
if (handler.canHandle(uri)) {
return await handler.handle(uri, source: source, payload: payload);
}
}
return false; // No handler claimed the URI
}
Practical implications:
- Register specific handlers before generic ones. A
RouteDeeplinkHandlerwith a wildcard pattern (/products/*) will consume URIs before a more specific custom handler if registered first. - The manager returns
falsewhen no handler claims a URI. Consider registering a catch-all fallback handler last if you want to log or report unhandled links. handleUrialso emits tomanager.onLinkbefore the handler chain runs, so stream listeners always receive every URI regardless of whether a handler claims it.
// Specific handler first
manager.registerHandler(ProductDeeplinkHandler());
// Generic route handler second: won't shadow the specific one
manager.registerHandler(
RouteDeeplinkHandler(paths: ['/products/*', '/shop/*']),
);