Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mayLaunch function #212

Merged
merged 6 commits into from
Nov 17, 2024
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
45 changes: 38 additions & 7 deletions flutter_custom_tabs/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,43 @@ void main() async {
runApp(MyApp(session));
}

class MyApp extends StatelessWidget {
final CustomTabsSession _session;
class MyApp extends StatefulWidget {
final CustomTabsSession customTabsSession;

const MyApp(
CustomTabsSession session, {
super.key,
}) : _session = session;
const MyApp(this.customTabsSession, {super.key});

@override
State createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
SafariViewPrewarmingSession? _prewarmingSession;

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

// After warming up, the session might not be established immediately, so we wait for a short period.
final customTabsSession = widget.customTabsSession;
Future.delayed(const Duration(seconds: 1), () async {
_prewarmingSession = await mayLaunchUrl(
Uri.parse('https://flutter.dev'),
customTabsSession: customTabsSession,
);
debugPrint('Warm up session: $_prewarmingSession');
});
}

@override
void dispose() {
final session = _prewarmingSession;
if (session != null) {
Future(() async {
await invalidateSession(session);
});
}
super.dispose();
}

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -88,7 +118,8 @@ class MyApp extends StatelessWidget {
child: const Text('Show flutter.dev in external browser'),
),
FilledButton.tonal(
onPressed: () => _launchURLWithSession(context, _session),
onPressed: () =>
_launchURLWithSession(context, widget.customTabsSession),
child: const Text('Show flutter.dev with session'),
),
],
Expand Down
73 changes: 61 additions & 12 deletions flutter_custom_tabs/lib/src/launcher.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_custom_tabs_android/flutter_custom_tabs_android.dart';
import 'package:flutter_custom_tabs_ios/flutter_custom_tabs_ios.dart';
Expand Down Expand Up @@ -71,16 +72,9 @@ Future<void> launchUrl(
bool prefersDeepLink = false,
CustomTabsOptions? customTabsOptions,
SafariViewControllerOptions? safariVCOptions,
}) async {
if (url.scheme != 'http' && url.scheme != 'https') {
throw PlatformException(
code: 'NOT_A_WEB_SCHEME',
message: 'Flutter Custom Tabs only supports URL of http or https scheme.',
);
}

await CustomTabsPlatform.instance.launch(
url.toString(),
}) {
return CustomTabsPlatform.instance.launch(
_requireWebUrl(url),
prefersDeepLink: prefersDeepLink,
customTabsOptions: customTabsOptions,
safariVCOptions: safariVCOptions,
Expand All @@ -93,8 +87,8 @@ Future<void> launchUrl(
/// - **Android:** Supported on SDK 23 (Android 6.0) and above.
/// - **iOS:** All versions.
/// - **Web:** Not supported.
Future<void> closeCustomTabs() async {
await CustomTabsPlatform.instance.closeAllIfPossible();
Future<void> closeCustomTabs() {
return CustomTabsPlatform.instance.closeAllIfPossible();
}

/// Pre-warms the Custom Tabs browser process, potentially improving performance when launching a URL.
Expand Down Expand Up @@ -145,6 +139,61 @@ Future<CustomTabsSession> warmupCustomTabs({
return session as CustomTabsSession? ?? const CustomTabsSession(null);
}

/// Notifies the browser of a potential URL that might be launched later,
/// improving performance when the URL is actually launched.
///
/// On **Android**, this method pre-fetches the web page at the specified URL.
/// This can improve page load time when the URL is launched later using [launchUrl].
/// For more details, see
/// [Warm-up and pre-fetch: using the Custom Tabs Service](https://developer.chrome.com/docs/android/custom-tabs/guide-warmup-prefetch).
///
/// On **iOS**, this method uses a best-effort approach to prewarming connections,
/// but may delay or drop requests based on the volume of requests made by your app.
/// Use this method when you expect to present [SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) soon.
/// Many HTTP servers time out connections after a few minutes.
/// After a timeout, prewarming delivers less performance benefit.
///
/// **Note:** It's crucial to call [invalidateSession] to release resources and properly dispose of the session when it is no longer needed.
///
/// ### Example
///
/// ```dart
/// final prewarmingSession = await mayLaunchUrl(
/// Uri.parse('https://flutter.dev'),
/// );
///
/// // Invalidates the session when the originating screen is disposed or in other cases where the session should be invalidated.
/// await invalidateSession(prewarmingSession);
/// ```
Future<SafariViewPrewarmingSession> mayLaunchUrl(
Uri url, {
CustomTabsSession? customTabsSession,
}) async {
final session = await CustomTabsPlatform.instance.mayLaunch(
[_requireWebUrl(url)],
session: switch (defaultTargetPlatform) {
TargetPlatform.android => customTabsSession,
_ => null,
},
);
return session as SafariViewPrewarmingSession? ??
const SafariViewPrewarmingSession(null);
}

/// Invalidates a session to release resources and properly dispose of it.
///
/// Use this method to invalidate a session that was created using [warmupCustomTabs] or [mayLaunchUrl] when it is no longer needed.
Future<void> invalidateSession(PlatformSession session) {
return CustomTabsPlatform.instance.invalidate(session);
}

String _requireWebUrl(Uri url) {
if (url.scheme != 'http' && url.scheme != 'https') {
throw ArgumentError.value(
url,
'url',
'must have an http or https scheme.',
);
}
return url.toString();
}
98 changes: 73 additions & 25 deletions flutter_custom_tabs/test/launcher_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_custom_tabs_platform_interface/flutter_custom_tabs_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart';
Expand All @@ -11,12 +11,11 @@ void main() {

test('launchUrl() launch with non-web URL', () async {
final url = Uri.parse('file:/home');
try {
await launchUrl(url);
fail("failed");
} catch (e) {
expect(e, isA<PlatformException>());
}
expect(
() => launchUrl(url),
throwsA(isA<ArgumentError>()),
);
expect(mock.launchUrlCalled, isFalse);
});

test('launchUrl() launch with null options', () async {
Expand All @@ -26,11 +25,11 @@ void main() {
prefersDeepLink: false,
);

try {
await launchUrl(url);
} catch (e) {
fail(e.toString());
}
expect(
() async => await launchUrl(url),
returnsNormally,
);
expect(mock.launchUrlCalled, isTrue);
});

test('launchUrl() launch with empty options', () async {
Expand All @@ -44,15 +43,15 @@ void main() {
safariVCOptions: safariVCOptions,
);

try {
await launchUrl(
expect(
() async => await launchUrl(
url,
customTabsOptions: customTabsOptions,
safariVCOptions: safariVCOptions,
);
} catch (e) {
fail(e.toString());
}
),
returnsNormally,
);
expect(mock.launchUrlCalled, isTrue);
});

test('launchUrl() launch with options', () async {
Expand All @@ -71,20 +70,23 @@ void main() {
safariVCOptions: safariVCOptions,
);

try {
await launchUrl(
expect(
() async => await launchUrl(
url,
prefersDeepLink: prefersDeepLink,
customTabsOptions: customTabsOptions,
safariVCOptions: safariVCOptions,
);
} catch (e) {
fail(e.toString());
}
),
returnsNormally,
);
expect(mock.launchUrlCalled, isTrue);
});

test('closeCustomTabs() invoke method "closeAllIfPossible"', () async {
await closeCustomTabs();
expect(
() async => await closeCustomTabs(),
returnsNormally,
);
expect(mock.closeAllIfPossibleCalled, isTrue);
});

Expand Down Expand Up @@ -126,6 +128,52 @@ void main() {
expect(mock.warmupCalled, isTrue);
});

test('mayLaunchUrl() launch with non-web URL', () async {
final url = Uri.parse('file:/home');
expect(
() => launchUrl(url),
throwsA(isA<ArgumentError>()),
);
});

test(
'mayLaunchUrl() invoke method "mayLaunch" with CustomTabsSession on Android',
() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;

const url = 'http://example.com/';
const customTabsSession = CustomTabsSession('com.example.browser');
mock.setMayLaunchExpectations(
urls: [url],
customTabsSession: customTabsSession,
);

final actualSession = await mayLaunchUrl(
Uri.parse(url),
customTabsSession: customTabsSession,
);
expect(actualSession.id, isNull);
expect(mock.mayLaunchCalled, isTrue);
});

test('mayLaunchUrl() invoke method "mayLaunch" with null on iOS', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;

const url = 'http://example.com/';
const prewarmingSession = SafariViewPrewarmingSession('test-session-id');
mock.setMayLaunchExpectations(
urls: [url],
customTabsSession: null,
prewarmingSession: prewarmingSession);

final actualSession = await mayLaunchUrl(
Uri.parse(url),
customTabsSession: null,
);
expect(actualSession.id, prewarmingSession.id);
expect(mock.mayLaunchCalled, isTrue);
});

test('invalidateSession() invoke method "invalidate" with CustomTabsSession',
() async {
const session = CustomTabsSession('com.example.browser');
Expand Down
44 changes: 37 additions & 7 deletions flutter_custom_tabs/test/mocks/mock_custom_tabs_platform.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ class MockCustomTabsPlatform extends Fake
with MockPlatformInterfaceMixin
implements CustomTabsPlatform {
String? url;
List<String>? urls;
bool? prefersDeepLink;
CustomTabsOptions? customTabsOptions;
SafariViewControllerOptions? safariVCOptions;
PlatformOptions? sessionOptions;
PlatformSession? session;
PlatformSession? argSession;
PlatformSession? returnSession;
bool launchUrlCalled = false;
bool closeAllIfPossibleCalled = false;
bool warmupCalled = false;
bool mayLaunchCalled = false;
bool invalidateCalled = false;

void setLaunchExpectations({
Expand All @@ -34,18 +37,28 @@ class MockCustomTabsPlatform extends Fake
PlatformSession? customTabsSession,
}) {
sessionOptions = customTabsOptions;
session = customTabsSession;
returnSession = customTabsSession;
}

void setMayLaunchExpectations({
required List<String> urls,
required PlatformSession? customTabsSession,
PlatformSession? prewarmingSession,
}) {
this.urls = urls;
argSession = customTabsSession;
returnSession = prewarmingSession;
}

void setInvalidateExpectations({
required PlatformSession session,
}) {
this.session = session;
argSession = session;
}

@override
Future<void> launch(
String? urlString, {
String urlString, {
bool? prefersDeepLink,
PlatformOptions? customTabsOptions,
PlatformOptions? safariVCOptions,
Expand Down Expand Up @@ -86,16 +99,33 @@ class MockCustomTabsPlatform extends Fake
expect(options, isNull);
}
warmupCalled = true;
return session;
return returnSession;
}

@override
Future<PlatformSession?> mayLaunch(
List<String> urls, {
PlatformSession? session,
}) async {
expect(urls, this.urls);

if (session is CustomTabsSession) {
final expected = argSession as CustomTabsSession;
expect(session.packageName, expected.packageName);
} else {
expect(session, isNull);
}
mayLaunchCalled = true;
return returnSession;
}

@override
Future<void> invalidate(PlatformSession session) async {
if (session is CustomTabsSession) {
final expected = this.session as CustomTabsSession;
final expected = argSession as CustomTabsSession;
expect(session.packageName, expected.packageName);
} else if (session is SafariViewPrewarmingSession) {
final expected = this.session as SafariViewPrewarmingSession;
final expected = argSession as SafariViewPrewarmingSession;
expect(session.id, expected.id);
} else {
expect(session, isNotNull);
Expand Down
Loading
Loading