Skip to content

Architecture

Deeplink Handling ​

Archipelago uses a dual-channel deeplink pattern that works for both cold-start and foreground navigation. The pattern is wired into the generated MainActivity (Android) and AppDelegate (iOS) of the main app and exposed to Dart through DeeplinkService, via two separate platform channels.

Architecture ​

flowchart TD
  subgraph native["Android/iOS native layer"]
    initial["MethodChannel<br/>archipelago/deeplink/initial"]
    events["EventChannel<br/>archipelago/deeplink/events"]
  end
  service["DeeplinkService<br/>(Dart, apps/#lt;app#gt;/lib/host/services/)"]
  routing["Your routing code<br/>(post-launch initializer + router)"]
  initial -->|"cold-start URI (one-shot)"| service
  events -->|foreground URI stream| service
  service --> routing

DeeplinkService is generated and registered as a lazy singleton. Routing deeplinks to screens is application-specific, so the generated project does not include a routing initializer. See Routing deeplinks below.

Why two channels? ​

  • MethodChannel (archipelago/deeplink/initial) — the native side captures the launch URI and Dart fetches it once with the getInitialLink method. The native side clears it after the first read.
  • EventChannel (archipelago/deeplink/events) — all subsequent deeplinks while the app is running are pushed as a stream. This channel is also used by NotificationActivity (from push_notification_sdk) to forward tap payloads.

Both native implementations also buffer a link that arrives before the Dart listener is attached, so a cold-start link is delivered through whichever channel Dart reaches first and never lost.

Android — MainActivity.kt ​

MainActivity extends FlutterFragmentActivity. Condensed:

kotlin
class MainActivity : FlutterFragmentActivity() {
    private var startDeepLink: String? = null
    private var eventSink: EventChannel.EventSink? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        startDeepLink = intent.data?.toString()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        val data = intent.data?.toString() ?: return
        eventSink?.success(data) ?: run { startDeepLink = data }
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        // MethodChannel "archipelago/deeplink/initial":
        //   "getInitialLink" -> returns startDeepLink once, then clears it
        // EventChannel "archipelago/deeplink/events":
        //   onListen stores the sink and flushes any buffered startDeepLink
    }
}

iOS — AppDelegate.swift ​

The app uses the UIScene lifecycle: Info.plist has UIApplicationSceneManifest with SceneDelegate (a plain FlutterSceneDelegate). iOS 27 stops an app without it at launch. In this lifecycle the Flutter engine starts with the first scene, after didFinishLaunching, so AppDelegate registers the plugins and a DeepLinkBridge in didInitializeImplicitFlutterEngine. The bridge is a scene delegate (registrar.addSceneDelegate): it reads the cold-start URL or Universal Link from the scene connection options, and later ones from scene(_:openURLContexts:) and scene(_:continue:). Condensed:

swift
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
    if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeepLinkBridge") {
      DeepLinkBridge.register(with: registrar)
    }
  }
}

final class DeepLinkBridge: NSObject, FlutterPlugin, FlutterSceneLifeCycleDelegate,
  FlutterStreamHandler {
  // register: the FlutterMethodChannel (getInitialLink), the
  // FlutterEventChannel, and registrar.addSceneDelegate(bridge).
  // willConnectTo / openURLContexts / continue userActivity -> receive(url),
  // each returning false so other plugins still see the link.
  // receive: send to the event sink, or keep it for getInitialLink.
}

Migrating a project generated before this change: copy ios/Runner/AppDelegate.swift from the brick (keep your own additions), and add the UIApplicationSceneManifest block that flutter create writes to ios/Runner/Info.plist. ios/Runner/SceneDelegate.swift is already in the project. Without this, the app does not start on iOS 27.

Both flutter_modular_monorepo (4.12.1 and later) and app_scaffold keep these two host files when they add the rest of the platform code. Projects from flutter_modular_monorepo 4.12.0 and earlier have Flutter's stock MainActivity/AppDelegate, without the channels, so deeplinks never reach Dart. Copy the two files from the brick to fix them.

Dart — DeeplinkService ​

apps/<app>/lib/host/services/deeplink_service.dart:

dart
@lazySingleton
class DeeplinkService {
  /// Subsequent deeplinks after cold start (broadcast stream).
  Stream<Uri> get deeplinkStream;

  /// Cold-start deeplink. Null if the app was not launched via a deeplink.
  /// Returned exactly once; later calls return null.
  Future<Uri?> getInitialLink();

  /// Cold-start link first, then every subsequent link.
  Stream<Uri> get unifiedStream;

  Future<void> dispose();
}

Invalid URIs are dropped silently. Use unifiedStream unless you need to treat cold-start and foreground links differently.

Wire the service into a post-launch initializer in your app so routing happens after the first frame. A sketch (not generated code, adapt it to your router and auth feature):

dart
class DeeplinkPostInitializer implements PostLaunchInitializer {
  const DeeplinkPostInitializer(this._deeplinkService, this._router, this._authSdk);

  @override
  Future<void> initialize() async {
    _deeplinkService.unifiedStream.listen(_handleLink);
  }

  void _handleLink(Uri uri) {
    // Auth guard — redirect to login if not authenticated
    if (_authSdk.status == AuthStatus.unauthenticated) {
      _router.push(LoginRoute(redirectTo: uri.toString()));
      return;
    }
    _router.pushNamed(uri.path);
  }
}

The generated DeeplinkService has a unit test at apps/<app>/test/host/services/deeplink_service_test.dart.

Android Intent Filters ​

Register deeplink URI schemes in android/app/src/main/AndroidManifest.xml:

xml
<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="yourapp.com" />
</intent-filter>
<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="yourapp" />
</intent-filter>

iOS URL Schemes ​

In Xcode: target → Info → URL Types → add your scheme. For Universal Links, add your domain to the Associated Domains capability and serve an AASA file at https://yourapp.com/.well-known/apple-app-site-association.

Domain verification files (.well-known/) ​

The project root contains a .well-known/ directory with assetlinks.json (Android App Links) and apple-app-site-association (iOS Universal Links), plus a README.md that explains each placeholder (Apple Team ID, release and debug SHA-256 fingerprints). Serve them unmodified from https://<your-domain>/.well-known/. The same files also carry the passkey (webcredentials) entries used by auth_sdk.