Deeplinking

Implementing Call Deep Linking

It's common in mobile calling apps that a call can be started from a link, that should start the app and dial in immediately. This can be accomplished by universal links on iOS and App links on Android.

In this guide we'll show you how to configure your app for call links, how to turn an incoming link into a call, and how to test the result.

There are two ways to handle the link once it reaches your app, and you must pick one:

  • Let your router handle it. Flutter hands the link to your app's Router, and you register a route for it. This is the recommended approach if you already use a routing package such as go_router.
  • Listen for links with a plugin, such as app_links, and navigate imperatively. Use this if you don't route declaratively.

Both are covered below. Start with the platform configuration, which is the same either way.

To handle deep links in Android you need to declare an intent filter in android/app/src/main/AndroidManifest.xml like it is shown in the example below:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter android:autoVerify="true" android:label="call_link">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:host="[YOUR_HOST]"
            android:pathPrefix="/join/"
            android:scheme="https" />
    </intent-filter>
</activity>

You can learn more about this structure in the official documentation, but the gist of it is this:

  • Adding the <intent-filter> lets your Activity parse specific intents that come from inside or outside of your application.
  • By adding the appropriate <action> and <category> tags, you let your Activity open and consume data that's browsable, such as URLs.
  • The most important part is the <data> tag. It allows you to parse the link in the format of https://[YOUR_HOST]/join/. Any URL that has that structure will be consumable by your Activity.

But to make your published app associate with a hosted and published website and to stop potential security issues, the website needs to know how to recognize your app.

You have to take your application package-id and its SHA-256 fingerprint and use it to generate an assetlinks.json file. You can find the full process and documentation on the official Android deep linking page.

Once you've generated these fingerprints - we recommend doing it for debug and release keystores and your CI if it uses a different keystore than local setup - you can create the assetlinks.json file:

[
  {
    // first application
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.my.application.debug",
      "sha256_cert_fingerprints": ["sha-256-fingerprint"]
    }
  },
  {
    // second application
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.my.application",
      "sha256_cert_fingerprints": ["sha-256-fingerprint"]
    }
  }
]

Note that the relation and namespace parts are not as important as the rest and how each object represents one application.

Now that you have the file created, you need to upload it either to the root directory of your website, or to the .well-known directory.

In order to support universal links, you need to have paid Apple developer account. On the Apple developer website, you will need to add the "associated domains" for your app id.

Next, you need to enable the "Associated Domains" capability for your app in Xcode, and specify an app link, in the format applinks:[YOUR_HOST]. After that your ios/Runner/Runner.entitlements file should look like this:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:[YOUR_HOST]</string>
    </array>
</dict>
</plist>

Next, you need to upload apple-app-site-association file, either to the root directory of your website, or to the .well-known directory. The AASA (short for apple-app-site-association) is a JSON file that lives on your website and associates your domain with your native app.

In its simplest form, your AASA file should have the following format:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "[YOUR_TEAM_ID].[YOUR_BUNDLE_ID]",
        "paths": ["*"]
      }
    ]
  }
}

You can also specify exact paths if you want to have stricter control over which ones can invoke your app. You can also specify several apps on the same domain.

Before proceeding, please make sure that the uploaded file is a valid one, and it's deployed at the right place. For this, you can use Apple's validation tool.

Flutter has a built-in deep link handler that delivers an incoming link to your app's Router as a location to match, exactly as if you had navigated there in the app. Whether it is active by default depends on the platform and on your Flutter version — recent versions forward links on iOS without any configuration, while Android requires you to opt in.

That default is the source of the most common deep linking bug in Flutter calling apps, so make the choice explicit in both platform files rather than relying on it.

Set the flags to match the approach you picked. For router-based handling, turn the handler on:

<!-- android/app/src/main/AndroidManifest.xml, inside <activity> -->
<meta-data
    android:name="flutter_deeplinking_enabled"
    android:value="true" />
<!-- ios/Runner/Info.plist -->
<key>FlutterDeepLinkingEnabled</key>
<true/>

For plugin-based handling, turn it off by setting the same two values to false. See Flutter's deep linking documentation for details.

Register a route for your call links. The examples below use go_router.

Nest the route under your home route rather than declaring it at the top level:

GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
      routes: [
        // Nested, so a link that opens the app builds a [home, join] stack.
        GoRoute(
          path: 'join/:callId',
          builder: (context, state) => JoinCallScreen(
            callId: state.pathParameters['callId']!,
          ),
        ),
      ],
    ),
    GoRoute(
      path: '/lobby',
      builder: (context, state) => LobbyScreen(call: state.extra! as Call),
    ),
  ],
)

Nesting matters. A top-level /join/:callId route is the only page on the stack when a link starts the app, so the screen it leads to has nothing beneath it — closing that screen, or leaving the call, pops the last page and throws You have popped the last page off of the stack, there are no pages left to show. Nesting the route under home makes the router build the parent page too, so every pop has somewhere to land.

Now add the screen that turns the call id into a call. Fetching the call is asynchronous, so this intermediate screen shows progress and then replaces itself with your lobby:

class JoinCallScreen extends StatefulWidget {
  const JoinCallScreen({super.key, required this.callId});

  final String callId;

  @override
  State<JoinCallScreen> createState() => _JoinCallScreenState();
}

class _JoinCallScreenState extends State<JoinCallScreen> {
  bool _failed = false;

  @override
  void initState() {
    super.initState();
    _openCall();
  }

  Future<void> _openCall() async {
    try {
      final call = StreamVideo.instance.makeCall(
        callType: StreamCallType.defaultType(),
        id: widget.callId,
      );
      await call.getOrCreate();

      if (!mounted) return;
      // Replaces this screen, so the stack becomes [home, lobby].
      context.replace('/lobby', extra: call);
    } catch (e, stk) {
      debugPrint('Could not open the call from the link: $e');
      debugPrint(stk.toString());
      if (mounted) setState(() => _failed = true);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _failed
            ? const Text('Could not open this call. Check that the link is still valid.')
            : const CircularProgressIndicator.adaptive(),
      ),
    );
  }
}

The link reaches the router as its full URL, so state.uri still carries the host and query parameters. If your links encode anything besides the call id — an environment, a call type, an invite token — read it from there.

A shared call link often starts the app from a terminated state, with no user connected yet. Dropping the link and showing the login screen loses the call the user was invited to, so hold on to the location and resume it once login completes:

Uri? _pendingLink;

GoRouter(
  // routes: ...
  refreshListenable: authNotifier,
  redirect: (context, state) {
    final loggedIn = authNotifier.currentUser != null;
    final loggingIn = state.matchedLocation == '/login';

    if (!loggedIn) {
      // Remember the link so it survives the trip through login.
      if (state.matchedLocation.startsWith('/join')) _pendingLink = state.uri;
      return loggingIn ? null : '/login';
    }

    if (loggingIn) {
      // Just logged in: honour a link that was waiting, otherwise go home.
      final pending = _pendingLink;
      _pendingLink = null;
      return pending?.toString() ?? '/';
    }

    return null;
  },
);

Make sure your GoRouter is given a refreshListenable that notifies when the user changes, otherwise the redirect never re-runs after login.

If you don't route declaratively, listen for links yourself with app_links and navigate imperatively. Remember to set flutter_deeplinking_enabled and FlutterDeepLinkingEnabled to false first, as described above.

Older versions of this guide used uni_links. That package is discontinued and its author recommends app_links instead.

dependencies:
  # Other dependencies
  app_links: <latest_version>
import 'package:app_links/app_links.dart';

StreamSubscription<Uri>? _linkSubscription;

/// A link that arrived before there was a user to join the call with.
Uri? _pendingLink;

Future<void> _observeDeepLinks() async {
  if (kIsWeb) return;

  // The app is already running.
  _linkSubscription = AppLinks().uriLinkStream.listen((uri) {
    if (mounted) _handleDeepLink(uri);
  });

  // The app was started by the link.
  try {
    final initialUri = await AppLinks().getInitialLink();
    if (initialUri != null) await _handleDeepLink(initialUri);
  } catch (e) {
    debugPrint(e.toString());
  }
}

Future<void> _handleDeepLink(Uri uri) async {
  // Use the same tolerant parsing as above: the id may be a path segment or a
  // query parameter.
  final callId = _callIdFromLink(uri);
  if (callId == null) return;

  // Replace getCurrentUser() with your own way of reading the current user.
  if (getCurrentUser() == null) {
    // Don't drop the link: keep it and open it once the user has logged in.
    _pendingLink = uri;
    return;
  }

  final call = StreamVideo.instance.makeCall(
    callType: StreamCallType.defaultType(),
    id: callId,
  );

  try {
    await call.getOrCreate();
  } catch (e, stk) {
    debugPrint('Error joining or creating call: $e');
    debugPrint(stk.toString());
    return;
  }

  // Your method to navigate to the lobby/call screen. Push it on top of your
  // home screen so the user has somewhere to go back to.
  navigateToLobby(call);
}

Cancel the subscription when the widget that owns it is disposed.

Run your application, generate a call link using the schema above, and open the link. It should open your app and take you to the call.

You can also trigger a link from the command line:

# Android
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://[YOUR_HOST]/join/call123"'

# iOS simulator
xcrun simctl openurl booted "https://[YOUR_HOST]/join/call123"