DeeplinkManager
On this page
Introduction#
DeeplinkManager is the central coordinator of the magic_deeplink plugin. It owns a single platform driver, an ordered list of URI handlers, and a broadcast stream that emits every incoming deep link. The DeeplinkServiceProvider creates and wires the manager during app boot: consuming code rarely needs to interact with it directly.
// Resolve the singleton anywhere
final manager = DeeplinkManager();
// Listen to all incoming deep links
manager.onLink.listen((uri) {
print('Received: $uri');
});
Singleton Pattern#
DeeplinkManager enforces a process-wide singleton via a private named constructor and a static final field:
class DeeplinkManager {
static final DeeplinkManager _instance = DeeplinkManager._internal();
factory DeeplinkManager() => _instance;
DeeplinkManager._internal();
}
Every call to DeeplinkManager() returns the same _instance. There is no public constructor that creates a new object.
Testing reset: Because the singleton persists across tests, each setUp must clear mutable state before asserting. forgetHandlers() and forgetDriver() alone leave two things behind: the cached initial link (so a second getInitialLink() answers the previous test's URI without ever reaching the driver) and the broadcast controller behind onLink (which nothing closes). The @visibleForTesting reset() method clears all four:
setUp(() {
manager.reset();
});
Driver Orchestration#
The manager delegates all platform I/O to a single DeeplinkDriver. The driver is not set at construction time: it is injected by DeeplinkServiceProvider.boot() after the IoC container is ready.
Setting a Driver#
void setDriver(DeeplinkDriver driver)
Assigns the active driver. Replaces any previously set driver without disposing it: the provider is responsible for driver lifecycle.
manager.setDriver(AppLinksDriver());
Removing a Driver#
void forgetDriver()
Sets _driver to null. Subsequent calls to driver (the getter) will throw a DeeplinkException with code NO_DRIVER until a new driver is set.
DeeplinkDriver get driver {
if (_driver == null) {
throw DeeplinkException(
'No deep link driver configured. Make sure to call setDriver() or register the service provider.',
code: 'NO_DRIVER',
);
}
return _driver!;
}
Driver Contract#
abstract class DeeplinkDriver {
String get name;
bool get isSupported;
Future<void> initialize(Map<String, dynamic> config);
Future<Uri?> getInitialLink();
Stream<Uri> get onLink;
void dispose();
}
The provider calls initialize() once during boot, then subscribes to onLink and pipes each emitted URI into manager.handleUri(uri, source: DeeplinkSource.osLink).
Handler Chain#
The manager maintains an ordered List<DeeplinkHandler>. When a URI arrives, the list is iterated in insertion order and the first handler whose canHandle() returns true wins: subsequent handlers are skipped.
Registering Handlers#
void registerHandler(DeeplinkHandler handler)
Appends the handler if it is not already in the list (identity check via List.contains). Duplicate registrations are silently ignored.
manager.registerHandler(RouteDeeplinkHandler());
manager.registerHandler(OneSignalDeeplinkHandler());
Checking presence:
bool hasHandler(DeeplinkHandler handler)
Returns true if the exact handler instance is registered.
Removing Handlers#
void forgetHandlers()
Clears the entire handler list. Used in test teardown and provider re-boot scenarios.
Handler Contract#
abstract class DeeplinkHandler {
bool canHandle(Uri uri);
Future<bool> handle(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
});
}
canHandle is synchronous: it inspects the URI and returns a boolean with no side effects. handle performs the actual work and returns true on success. Handlers must never throw; they return false on failure. source says whether the URI arrived as an OS-opened link or a push notification's own payload, and payload carries that push's data (null on every other path); see doc/basics/handlers.md for what a handler may trust from each.
URI Stream#
final StreamController<Uri> _linkController = StreamController<Uri>.broadcast();
Stream<Uri> get onLink => _linkController.stream;
A broadcast controller is used so multiple listeners (e.g., analytics, routing, tests) can subscribe independently without coordinating. The stream is never closed during normal app operation: it lives for the full process lifetime alongside the singleton.
Listening:
manager.onLink.listen((uri) {
// Called for every URI that passes through handleUri()
});
handleUri Flow#
Future<bool> handleUri(
Uri uri, {
required DeeplinkSource source,
Map<String, dynamic>? payload,
}) async {
_linkController.add(uri);
for (final handler in _handlers) {
if (handler.canHandle(uri)) {
return await handler.handle(uri, source: source, payload: payload);
}
}
return false;
}
The method does two things unconditionally and sequentially:
- Emit: the URI is added to
_linkControllerbefore any handler runs. Every stream subscriber receives the URI regardless of whether a handler exists. - Dispatch: the handler list is iterated in order. The first handler for which
canHandle(uri)istrueis invoked withsourceandpayloadthreaded through. If it succeeds,trueis returned and iteration stops. If no handler matches,falseis returned.
source and payload are required at the call site because they cannot be recovered from uri alone: OneSignalDeeplinkHandler calls this with source: DeeplinkSource.push and the full notification payload, while the provider's own driver subscription calls it with source: DeeplinkSource.osLink and no payload.
The stream emission is intentionally first so that observers see all URIs, not only those that are handled.
Initial Link Caching#
When an app is launched by tapping a deep link, the link is available via getInitialLink(). The manager caches the result after the first driver call so repeated calls are cheap and deterministic:
Future<Uri?> getInitialLink() async {
if (_initialLinkFetched) {
return _initialLink;
}
_initialLink = await driver.getInitialLink();
_initialLinkFetched = true;
return _initialLink;
}
| State | Behavior |
|---|---|
First call (_initialLinkFetched == false) |
Delegates to driver.getInitialLink(), stores result, sets flag |
Subsequent calls (_initialLinkFetched == true) |
Returns cached _initialLink immediately, no driver call |
_initialLink may be null if the app was launched normally (not via a deep link). The null result is cached just like a non-null URI: the flag is set either way.
Testing implication: forgetDriver() does not reset _initialLinkFetched. If a test exercises initial link caching, it must interact with the singleton before the flag is set, or use a fresh manager instance via the private constructor in test subclasses.
Data Flow#
App launch
│
▼
DeeplinkServiceProvider.boot()
│
├─ return early if config.deeplink.enabled == false
│
├─ manager.setDriver(driver) (only when driver.isSupported)
│
├─ driver.initialize(config)
│
└─ driver.onLink.listen(uri => deliver after endOfFrame)
│
▼
manager.handleUri(uri, source: DeeplinkSource.osLink)
│
├─ _linkController.add(uri) ──► onLink stream subscribers
│
└─ iterate _handlers in order
│
├─ handler.canHandle(uri) == false ──► next handler
│
└─ handler.canHandle(uri) == true
│
▼
handler.handle(uri, source: source, payload: payload)
│
└─ return bool ──► handleUri returns
The cold-start link is delivered on this same stream: app_links serves it as the first emission of its link stream rather than through a separate read, so the provider no longer calls manager.getInitialLink() itself. That method remains on DeeplinkManager for a consumer that wants to ask directly, caching its result exactly as before.
Related#
lib/src/deeplink_manager.dart: full sourcelib/src/drivers/deeplink_driver.dart: driver contractlib/src/handlers/deeplink_handler.dart: handler contractlib/src/providers/deeplink_service_provider.dart: wires manager during app bootlib/src/exceptions/deeplink_exception.dart:DeeplinkExceptionwithNO_DRIVERcode