Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/url_launcher_watchos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.1.2

* Example: `url_launcher`'s own example app and its official integration
test, unchanged, replace a hand-written demo (`AUTHORING.md` §3). The
former test stays beside it as `watchos_url_launcher_test.dart`.

## 0.1.1

* README: the install snippet names `url_launcher: ^6.3.2` instead of `any`,
Expand Down
12 changes: 10 additions & 2 deletions packages/url_launcher_watchos/PORTING_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
| Host unit tests (`flutter-watchos test`) | ✅ pass (10) |
| Native compile (`arm64-apple-watchos7.0-simulator`, `-Wall -Wextra`) | ✅ clean, all 3 symbols exported |
| CLI integration | ✅ `url_launcher` drops off the "no watchOS implementation" warning in a real app |
| Upstream integration test | ✗ not reused — upstream asserts an in-app `SFSafariViewController`/webview surface that cannot exist on watchOS |
| Own integration test (Simulator, real FFI) | ✅ pass (5) — symbols resolve via `DynamicLibrary.process()` and `url_launcher` federates to this implementation |
| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (`url_launcher`'s `url_launcher_test.dart`, 2026-10-24) |
| watchOS integration test (`watchos_url_launcher_test.dart`, Simulator, real FFI) | ✅ pass (6) — symbols resolve via `DynamicLibrary.process()` and `url_launcher` federates to this implementation |
| On-device (Apple Watch Ultra 3, watchOS 26.5, release AOT) | ✅ `tel:` and `sms:` confirmed by hand — both raise the system call / compose UI on the watch. Web path confirmed too: the system sheet appears and reports the link can be viewed on the iPhone |
| On-device Handoff pickup on the phone | ✅ confirmed — the Handoff icon appears in the paired iPhone's app switcher and opens the page |

Expand All @@ -29,6 +29,14 @@ scaffold's shape (package name, `dartPluginClass: UrlLauncherWatchos`,
`ffiPlugin: true`, `implements: url_launcher`) matches this package; the
exported symbols and every method body are the human half it leaves behind.

## Example

`url_launcher`'s own example app and its official integration test, ported
unchanged with `flutter-watchos plugin port --include-example`. There are no
watchOS deviations. The official test checks `canLaunchUrl` and the legacy
`canLaunch`. `watchos_url_launcher_test.dart` beside it checks the on-watch
browser, Handoff and the refused schemes.

## Status

✅ WORKING FFI implementation:
Expand Down
9 changes: 9 additions & 0 deletions packages/url_launcher_watchos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ These members of `url_launcher_platform_interface` 2.3.2 throw or fail on watchO
| `launchUrl` / `launch` with a scheme other than `http:`, `https:`, `tel:` and `sms:` (`mailto:` included) | return false | `openSystemURL:` owns only `tel:` and `sms:` and fails silently on other schemes |
| `supportsMode(externalNonBrowserApplication)` | returns false | the watch cannot tell whether the iPhone opens a web URL in an app; `launchUrl` still launches it as `externalApplication` |

## Example

The example is `url_launcher`'s own example app, and
`example/integration_test/url_launcher_test.dart` is its official test, both
unchanged, ported with `flutter-watchos plugin port --include-example`. There
are no watchOS deviations. `example/integration_test/watchos_url_launcher_test.dart`
covers what the official test does not: the on-watch browser, Handoff and
the schemes the watch refuses.

## Testing

`UrlLauncherWatchos.bindingsOverride` accepts a fake extending
Expand Down
Original file line number Diff line number Diff line change
@@ -1,70 +1,35 @@
// Copyright 2026 The FlutterWatch Authors. All rights reserved.
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//
// Runs on a real watch (or the Simulator) against the linked FFI symbols, so
// it proves the native side is present and answering — something the host
// unit tests, which fake the bindings, cannot.
//
// What it deliberately does NOT assert: that a URL actually opened. Neither
// the on-watch browser, `openSystemURL:` nor Handoff reports completion to the
// app, and the watch has no API to observe the paired phone. Those need a
// human with a wrist.

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('canLaunchUrl (real FFI)', () {
test('accepts the schemes watchOS can act on', () async {
expect(await canLaunchUrl(Uri.parse('tel:+15551234567')), isTrue);
expect(await canLaunchUrl(Uri.parse('sms:+15551234567')), isTrue);
expect(await canLaunchUrl(Uri.parse('https://flutterwatch.dev')), isTrue);
expect(await canLaunchUrl(Uri.parse('http://example.com')), isTrue);
});

test('refuses schemes with no watchOS mechanism', () async {
expect(
await canLaunchUrl(Uri.parse('mailto:hello@example.com')), isFalse);
expect(await canLaunchUrl(Uri.parse('ftp://example.com')), isFalse);
});
});

group('launchUrl (real FFI)', () {
test('a web URL is accepted for the on-watch browser', () async {
// True means "handed to ASWebAuthenticationSession", not "rendered".
expect(await launchUrl(Uri.parse('https://flutterwatch.dev')), isTrue);
await closeInAppWebView();
});

test('a web URL is accepted for Handoff with externalApplication',
() async {
// True means "published as an NSUserActivity", not "opened".
expect(
await launchUrl(
Uri.parse('https://flutterwatch.dev'),
mode: LaunchMode.externalApplication,
),
isTrue,
);
});

test('an unsupported scheme is refused, not silently swallowed', () async {
// Reports false rather than throwing: the caller gets a definite "this
// did not happen" instead of an exception to catch.
expect(await launchUrl(Uri.parse('ftp://example.com')), isFalse);
});

test('closeWebView withdraws the Handoff activity without throwing',
() async {
await launchUrl(
Uri.parse('https://flutterwatch.dev'),
mode: LaunchMode.externalApplication,
);
await closeInAppWebView();
});
testWidgets('canLaunch', (WidgetTester _) async {
expect(
await canLaunchUrl(Uri(scheme: 'randomscheme', path: 'a_path')), false);

// Generally all devices should have some default browser.
expect(await canLaunchUrl(Uri(scheme: 'http', host: 'flutter.dev')), true);
expect(await canLaunchUrl(Uri(scheme: 'https', host: 'flutter.dev')), true);

// SMS handling is available by default on most platforms.
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
expect(await canLaunchUrl(Uri(scheme: 'sms', path: '5555555555')), true);
}

// Sanity-check legacy API.
// ignore: deprecated_member_use
expect(await canLaunch('randomstring'), false);
// Generally all devices should have some default browser.
// ignore: deprecated_member_use
expect(await canLaunch('https://flutter.dev'), true);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright 2026 The FlutterWatch Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//
// Runs on a real watch (or the Simulator) against the linked FFI symbols, so
// it proves the native side is present and answering — something the host
// unit tests, which fake the bindings, cannot.
//
// What it deliberately does NOT assert: that a URL actually opened. Neither
// the on-watch browser, `openSystemURL:` nor Handoff reports completion to the
// app, and the watch has no API to observe the paired phone. Those need a
// human with a wrist.

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('canLaunchUrl (real FFI)', () {
test('accepts the schemes watchOS can act on', () async {
expect(await canLaunchUrl(Uri.parse('tel:+15551234567')), isTrue);
expect(await canLaunchUrl(Uri.parse('sms:+15551234567')), isTrue);
expect(await canLaunchUrl(Uri.parse('https://flutterwatch.dev')), isTrue);
expect(await canLaunchUrl(Uri.parse('http://example.com')), isTrue);
});

test('refuses schemes with no watchOS mechanism', () async {
expect(
await canLaunchUrl(Uri.parse('mailto:hello@example.com')), isFalse);
expect(await canLaunchUrl(Uri.parse('ftp://example.com')), isFalse);
});
});

group('launchUrl (real FFI)', () {
test('a web URL is accepted for the on-watch browser', () async {
// True means "handed to ASWebAuthenticationSession", not "rendered".
expect(await launchUrl(Uri.parse('https://flutterwatch.dev')), isTrue);
await closeInAppWebView();
});

test('a web URL is accepted for Handoff with externalApplication',
() async {
// True means "published as an NSUserActivity", not "opened".
expect(
await launchUrl(
Uri.parse('https://flutterwatch.dev'),
mode: LaunchMode.externalApplication,
),
isTrue,
);
});

test('an unsupported scheme is refused, not silently swallowed', () async {
// Reports false rather than throwing: the caller gets a definite "this
// did not happen" instead of an exception to catch.
expect(await launchUrl(Uri.parse('ftp://example.com')), isFalse);
});

test('closeWebView withdraws the Handoff activity without throwing',
() async {
await launchUrl(
Uri.parse('https://flutterwatch.dev'),
mode: LaunchMode.externalApplication,
);
await closeInAppWebView();
});
});
}
33 changes: 33 additions & 0 deletions packages/url_launcher_watchos/example/lib/basic.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Run this example with: flutter run -t lib/basic.dart -d emulator

// This file is used to extract code samples for the README.md file.
// Run update-excerpts if you modify this file.

// #docregion basic-example
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

final Uri _url = Uri.parse('https://flutter.dev');

void main() => runApp(
const MaterialApp(
home: Material(
child: Center(
child: ElevatedButton(
onPressed: _launchUrl, child: Text('Show Flutter homepage')),
),
),
),
);

Future<void> _launchUrl() async {
if (!await launchUrl(_url)) {
throw Exception('Could not launch $_url');
}
}

// #enddocregion basic-example
53 changes: 53 additions & 0 deletions packages/url_launcher_watchos/example/lib/encoding.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Run this example with: flutter run -t lib/encoding.dart -d emulator

// This file is used to extract code samples for the README.md file.
// Run update-excerpts if you modify this file.

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

/// Encode [params] so it produces a correct query string.
/// Workaround for: https://github.com/dart-lang/sdk/issues/43838
// #docregion encode-query-parameters
String? encodeQueryParameters(Map<String, String> params) {
return params.entries
.map(
(MapEntry<String, String> e) =>
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
)
.join('&');
}

// #enddocregion encode-query-parameters

void main() => runApp(
const MaterialApp(
home: Material(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
ElevatedButton(
onPressed: _composeMail, child: Text('Compose an email')),
],
),
),
),
);

void _composeMail() {
// #docregion encode-query-parameters
final emailLaunchUri = Uri(
scheme: 'mailto',
path: 'smith@example.com',
query: encodeQueryParameters(<String, String>{
'subject': 'Example Subject & Symbols are allowed!',
}),
);

launchUrl(emailLaunchUri);
// #enddocregion encode-query-parameters
}
45 changes: 45 additions & 0 deletions packages/url_launcher_watchos/example/lib/files.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Run this example with: flutter run -t lib/files.dart -d linux

// This file is used to extract code samples for the README.md file.
// Run update-excerpts if you modify this file.
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:url_launcher/url_launcher.dart';

void main() => runApp(
const MaterialApp(
home: Material(
child: Center(
child:
ElevatedButton(onPressed: _openFile, child: Text('Open File')),
),
),
),
);

Future<void> _openFile() async {
// Prepare a file within tmp
final String tempFilePath = p.joinAll(<String>[
...p.split(Directory.systemTemp.path),
'flutter_url_launcher_example.txt',
]);
final testFile = File(tempFilePath);
await testFile.writeAsString('Hello, world!');
// #docregion file
final String filePath = testFile.absolute.path;
final uri = Uri.file(filePath);

if (!File(uri.toFilePath()).existsSync()) {
throw Exception('$uri does not exist!');
}
if (!await launchUrl(uri)) {
throw Exception('Could not launch $uri');
}
// #enddocregion file
}
Loading
Loading