Skip to content

Handlers

View raw .md
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:

dart
abstract class DeeplinkHandler {
  bool canHandle(Uri uri);
  Future<bool> handle(
    Uri uri, {
    required DeeplinkSource source,
    Map<String, dynamic>? payload,
  });
}

canHandle#

dart
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#

dart
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#

dart
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:

dart
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 http or https, with no explicit port and no userinfo, and only when its host equals one entry of hosts case-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 hosts is 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.
dart
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:

dart
final handler = RouteDeeplinkHandler(
  paths: [
    '/products/:id',
    '/shop/*',
    '/promotions',
  ],
);

When a matching URI arrives, the handler calls:

dart
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:

dart
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.push switches. An osLink or a manual link 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 5 and a local '5' are the same tenant.
  • No evidence, no switch. An absent or blank payload tenant, or a null current tenant, navigates directly.
  • A failed switch does not navigate. switchTenant answering false calls onSwitchFailed, logs an error and answers false; opening the page anyway would meet the same 404. A switchTenant that throws counts as a failed switch: it calls onSwitchFailed, is logged, and answers false without navigating. An onSwitched that throws is logged and the page still opens, because by then the session is already on the new tenant.

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.

dart
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:

dart
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:

dart
void dispose()

Cancels the internal stream subscription. Call this in your service provider's teardown or when the handler is no longer needed.

Example:

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

dart
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:

  • canHandle must be synchronous and side-effect free.
  • handle must never throw: wrap async operations in try-catch.
  • Return false (not throw) when the URI cannot be handled after canHandle returned true.
  • Trust payload only when source == DeeplinkSource.push; an osLink carries 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.

dart
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:

dart
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:

dart
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 RouteDeeplinkHandler with a wildcard pattern (/products/*) will consume URIs before a more specific custom handler if registered first.
  • The manager returns false when no handler claims a URI. Consider registering a catch-all fallback handler last if you want to log or report unhandled links.
  • handleUri also emits to manager.onLink before the handler chain runs, so stream listeners always receive every URI regardless of whether a handler claims it.
dart
// Specific handler first
manager.registerHandler(ProductDeeplinkHandler());

// Generic route handler second: won't shadow the specific one
manager.registerHandler(
  RouteDeeplinkHandler(paths: ['/products/*', '/shop/*']),
);
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.