Flutter url_launcher: Open Links, Email, Phone, and Maps

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for opening links, email, phone, and maps.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for opening links, email, phone, and maps.

A support button should open a prepared email, a telephone number should reach the dialler, and a venue link should hand the destination to a mapping application. Flutter's url_launcher package provides one API for these jobs, but each URI scheme and launch mode has its own platform constraints.

Follow me on Instagram@sagnikteaches

This tutorial builds a small launcher screen for HTTPS pages, email, telephone calls, text messages, and coordinates. It also covers external applications, an in-app web view, Android package visibility, iOS scheme declarations, and failures that users can recover from.

Connect on LinkedInSagnik Bhattacharya

Add the current stable url_launcher release with flutter pub add url_launcher. Test telephone, SMS, and mapping actions on physical devices as simulators frequently lack the applications needed to handle them.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Give every action a properly formed Uri

launchUrl accepts a Uri, not an unchecked string. Using Uri constructors keeps recipients, query values, spaces, and punctuation separate, which matters when an email subject or SMS body contains user-entered text.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
  runApp(const LauncherApp());
}

class LauncherApp extends StatelessWidget {
  const LauncherApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Contact launcher',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const LauncherPage(),
    );
  }
}

class LauncherPage extends StatefulWidget {
  const LauncherPage({super.key});

  @override
  State<LauncherPage> createState() => _LauncherPageState();
}

class _LauncherPageState extends State<LauncherPage> {
  Future<void> _open(
    Uri uri, {
    LaunchMode mode = LaunchMode.externalApplication,
  }) async {
    try {
      final opened = await launchUrl(uri, mode: mode);

      if (!opened) {
        _showMessage('No application could open this link.');
      }
    } on PlatformException catch (error) {
      _showMessage(error.message ?? 'The link could not be opened.');
    } on ArgumentError {
      _showMessage('The link is not valid.');
    }
  }

  void _showMessage(String message) {
    if (!mounted) return;

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  @override
  Widget build(BuildContext context) {
    final actions = <(String, VoidCallback)>[
      (
        'Open documentation',
        () => _open(Uri.https('docs.flutter.dev')),
      ),
      (
        'Read inside the app',
        () => _open(
          Uri.https('docs.flutter.dev', '/cookbook'),
          mode: LaunchMode.inAppWebView,
        ),
      ),
      ('Email support', () => _open(_supportEmail())),
      ('Call support', () => _open(Uri.parse('tel:+442071838750'))),
      ('Text support', () => _open(_supportMessage())),
      ('Open map', _openMap),
    ];

    return Scaffold(
      appBar: AppBar(title: const Text('Contact options')),
      body: ListView.separated(
        padding: const EdgeInsets.all(16),
        itemCount: actions.length,
        separatorBuilder: (_, __) => const Divider(),
        itemBuilder: (context, index) {
          final (label, action) = actions[index];
          return ListTile(
            title: Text(label),
            trailing: const Icon(Icons.open_in_new),
            onTap: action,
          );
        },
      ),
    );
  }

  Uri _supportEmail() {
    return Uri(
      scheme: 'mailto',
      path: 'support@example.com',
      query: _encodeQuery({
        'subject': 'Help with my order',
        'body': 'Order number: 12345\n\nPlease describe the issue.',
      }),
    );
  }

  Uri _supportMessage() {
    return Uri(
      scheme: 'sms',
      path: '+442071838750',
      query: _encodeQuery({
        'body': 'Please call me about order 12345.',
      }),
    );
  }

  Future<void> _openMap() async {
    final place = Uri.encodeComponent('British Museum');
    final geoUri = Uri.parse('geo:51.5194,-0.1270?q=$place');

    try {
      if (await launchUrl(
        geoUri,
        mode: LaunchMode.externalApplication,
      )) {
        return;
      }
    } on PlatformException {
      // Continue to the portable HTTPS fallback.
    }

    await _open(
      Uri.https(
        'www.google.com',
        '/maps/search/',
        {'api': '1', 'query': 'British Museum'},
      ),
    );
  }
}

String _encodeQuery(Map<String, String> parameters) {
  return parameters.entries
      .map(
        (entry) =>
            '${Uri.encodeComponent(entry.key)}='
            '${Uri.encodeComponent(entry.value)}',
      )
      .join('&');
}

The helper handles both documented failure channels: a false result and a platform exception. Its mounted check also prevents a delayed result from using a BuildContext after the page has been removed.

Choose externalApplication when another app owns the task

An HTTPS address can open in several ways. LaunchMode.externalApplication explicitly leaves Flutter and asks the operating system to select an external handler. It is appropriate for pages users may bookmark, payment journeys that require another application, and links intended for their normal browser.

Future<bool> openPrivacyPolicy() {
  final uri = Uri.https(
    'example.com',
    '/privacy',
    {'source': 'flutter_app'},
  );

  return launchUrl(
    uri,
    mode: LaunchMode.externalApplication,
  );
}

Do not assume the call succeeded merely because the Uri parsed. A managed device may have no permitted browser, a malformed host may be rejected, or platform policy may block the hand-off. Check the returned Boolean and display an actionable message rather than silently ignoring the tap.

Keep selected web content inside the application

For an HTTP or HTTPS page that belongs to a short, contained journey, request LaunchMode.inAppWebView. This mode is for web addresses; it is not a container for mailto, tel, sms, or geo URIs.

Future<void> openHelpCentre() async {
  final uri = Uri.https('example.com', '/help');

  final openedInApp = await launchUrl(
    uri,
    mode: LaunchMode.inAppWebView,
  );

  if (!openedInApp) {
    final openedExternally = await launchUrl(
      uri,
      mode: LaunchMode.externalApplication,
    );

    if (!openedExternally) {
      throw StateError('No available mode could open $uri');
    }
  }
}

An embedded page still needs sensible navigation and a privacy review. Avoid placing untrusted authentication or payment pages inside a web view when their provider expects the system browser. Always retain the external fallback because support for a launch mode varies by platform.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Compose mailto, tel, and sms actions without corrupting text

A mailto: URI can include a recipient, subject, and body. Build its query manually with Uri.encodeComponent; some mail clients do not interpret the plus signs that a general form-style query encoder may use for spaces. The same technique gives an SMS body safe escaping.

String encodeParameters(Map<String, String> values) {
  return values.entries
      .map(
        (entry) =>
            '${Uri.encodeComponent(entry.key)}='
            '${Uri.encodeComponent(entry.value)}',
      )
      .join('&');
}

final email = Uri(
  scheme: 'mailto',
  path: 'hello@example.com',
  query: encodeParameters({
    'subject': 'Accessibility feedback',
    'body': 'I found an issue on the checkout screen.',
  }),
);

final telephone = Uri.parse('tel:+442071838750');

final message = Uri(
  scheme: 'sms',
  path: '+442071838750',
  query: encodeParameters({
    'body': 'Could you confirm tomorrow’s appointment?',
  }),
);

Launching tel: opens the dialler with a number; it should not start a call without the user's confirmation. SMS body handling can differ between installed messaging applications, so treat pre-filled text as a convenience and let the user review it before sending.

Use geo with a portable maps fallback

Android mapping applications commonly understand geo:latitude,longitude. Adding a q query supplies a visible search label or place, while 0,0 can be used when only a text search matters.

final museumName = Uri.encodeComponent('National Museum of Scotland');
final geo = Uri.parse(
  'geo:55.9470,-3.1890?q=$museumName',
);

final webMap = Uri.https(
  'www.google.com',
  '/maps/search/',
  {
    'api': '1',
    'query': 'National Museum of Scotland',
  },
);

The geo scheme is not universal, particularly across iOS mapping configurations. Attempt it, inspect the result, and then open an HTTPS maps search as shown in the complete example. Do not manufacture coordinates from a nullable location result; omit the coordinate action or use a deliberate text search when location data is unavailable.

Declare Android package queries used by canLaunchUrl

Android 11 and later restrict information about installed applications. If the application calls canLaunchUrl for non-web schemes, declare matching intents in android/app/src/main/AndroidManifest.xml. Place <queries> directly inside <manifest>, alongside rather than inside <application>.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="mailto" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="tel" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="sms" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="geo" />
        </intent>
    </queries>

    <application
        android:label="launcher_example"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <!-- Existing activities remain here. -->
    </application>
</manifest>

List only schemes the application actually queries. Broad package visibility is unnecessary for this task and attracts additional Google Play scrutiny. These entries enable discovery; they do not install a browser, mail client, dialler, messaging application, or maps application.

Allow iOS to query the schemes it checks

On iOS, schemes passed to canLaunchUrl may need entries under LSApplicationQueriesSchemes in ios/Runner/Info.plist. Add the array inside the top-level <dict> and retain the surrounding keys generated by Flutter.

<key>LSApplicationQueriesSchemes</key>
<array>
    <string>mailto</string>
    <string>tel</string>
    <string>sms</string>
    <string>geo</string>
</array>

This configuration affects whether the application may ask about handlers; it does not guarantee one exists. The iOS Simulator cannot make telephone calls and may not have a configured mail account, so a false result there is not evidence that a physical device will behave identically.

Avoid turning canLaunchUrl into a false gate

canLaunchUrl is useful when the interface must decide whether to expose an optional integration. However, it can return false when Android <queries> or iOS LSApplicationQueriesSchemes is missing, even though a direct launch might succeed.

Future<bool> hasSmsHandler() async {
  final uri = Uri.parse('sms:+442071838750');

  try {
    return await canLaunchUrl(uri);
  } on PlatformException {
    return false;
  }
}

Future<void> launchKnownWebAddress() async {
  final uri = Uri.https('example.com', '/support');
  final opened = await launchUrl(
    uri,
    mode: LaunchMode.externalApplication,
  );

  if (!opened) {
    throw StateError('Unable to open the support page.');
  }
}

For a normal button, attempting launchUrl and handling failure avoids a time-of-check/time-of-use race and false-negative preflight checks. Use canLaunchUrl when its answer genuinely changes the interface, configure every queried scheme, and still handle launch failure because availability can change between the two calls.

Verify results and catch scheme-specific mistakes

Run the application on at least one current Android device and one iPhone. Check an installed-handler path and a missing-handler path for each non-web scheme, confirm that external HTTPS links leave the application, and ensure the in-app page can be dismissed. Also test subjects, bodies, and map labels containing spaces, apostrophes, ampersands, and non-ASCII characters.

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('email query values survive URI encoding', () {
    final uri = Uri(
      scheme: 'mailto',
      path: 'help@example.com',
      query: encodeParameters({
        'subject': 'Returns & refunds',
        'body': 'Order café-42 needs attention.',
      }),
    );

    expect(uri.scheme, 'mailto');
    expect(uri.path, 'help@example.com');
    expect(
      Uri.splitQueryString(uri.query),
      {
        'subject': 'Returns & refunds',
        'body': 'Order café-42 needs attention.',
      },
    );
  });
}

String encodeParameters(Map<String, String> values) {
  return values.entries
      .map(
        (entry) =>
            '${Uri.encodeComponent(entry.key)}='
            '${Uri.encodeComponent(entry.value)}',
      )
      .join('&');
}

Common failures include passing a raw string instead of a Uri, forgetting to await the result, using inAppWebView for a telephone or email scheme, and hiding a button solely because canLaunchUrl returned false. Another subtle error is placing Android <queries> inside <application>; the manifest will not express the intended package visibility there. Treat each URI as untrusted input when any part comes from a server, and permit only schemes your product intentionally supports.

Further reads

Keep going with these related tutorials from this site.