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
5 changes: 5 additions & 0 deletions packages/url_launcher/url_launcher_ios/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 6.4.0

* Improves compatibility with `UIScene`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refers to the removal of UIApplication.shared.keyWindow?.rootViewController?.topViewController right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, that's the substantive change here; the rest is testability.

* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10.

## 6.3.6

* Updates to Pigeon 26.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,32 @@ private func urlParsingIsStrict() -> Bool {
return URL(string: "b a d U R L") == nil
}

final class URLLauncherTests: XCTestCase {
final class TestViewPresenter: ViewPresenter {
public var presentedController: UIViewController?

private func createPlugin() -> URLLauncherPlugin {
let launcher = FakeLauncher()
return URLLauncherPlugin(launcher: launcher)
func present(
_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil
) {
presentedController = viewControllerToPresent
}
}

final class StubViewPresenterProvider: ViewPresenterProvider {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we use the same prefix "Test-" above and "Stub-" here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't do the same things; StubViewPresenterProvider is a stub, in that it just returns a fixed value, while TestViewPresenter tracks calls and updates its state (more like a mock).

var viewPresenter: ViewPresenter?

init(viewPresenter: ViewPresenter?) {
self.viewPresenter = viewPresenter
}
}

final class URLLauncherTests: XCTestCase {

private func createPlugin(launcher: FakeLauncher) -> URLLauncherPlugin {
return URLLauncherPlugin(launcher: launcher)
private func createPlugin(
launcher: FakeLauncher = FakeLauncher(), viewPresenter: ViewPresenter? = TestViewPresenter()
) -> URLLauncherPlugin {
return URLLauncherPlugin(
launcher: launcher,
viewPresenterProvider: StubViewPresenterProvider(viewPresenter: viewPresenter))
}

func testCanLaunchSuccess() {
Expand Down Expand Up @@ -133,7 +150,8 @@ final class URLLauncherTests: XCTestCase {

func testLaunchSafariViewControllerWithClose() {
let launcher = FakeLauncher()
let plugin = createPlugin(launcher: launcher)
let viewPresenter = TestViewPresenter()
let plugin = createPlugin(launcher: launcher, viewPresenter: viewPresenter)

let expectation = XCTestExpectation(description: "completion called")
plugin.openUrlInSafariViewController(url: "https://flutter.dev") { result in
Expand All @@ -147,6 +165,23 @@ final class URLLauncherTests: XCTestCase {
}
plugin.closeSafariViewController()
wait(for: [expectation], timeout: 30)
XCTAssertNotNil(viewPresenter.presentedController)
}

func testLaunchSafariViewControllerFailureWithNoViewPresenter() {
let expectation = XCTestExpectation(description: "completion called")
createPlugin(viewPresenter: nil).openUrlInSafariViewController(url: "https://flutter.dev") {
result in
switch result {
case .success(let details):
XCTAssertEqual(details, .noUI)
case .failure(let error):
XCTFail("Unexpected error: \(error)")
}
expectation.fulfill()
}

wait(for: [expectation], timeout: 1)
}

}
Expand Down
4 changes: 2 additions & 2 deletions packages/url_launcher/url_launcher_ios/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ description: Demonstrates how to use the url_launcher plugin.
publish_to: none

environment:
sdk: ^3.9.0
flutter: ">=3.35.0"
sdk: ^3.10.0
flutter: ">=3.38.0"

dependencies:
flutter:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,20 @@ import UIKit
public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi {

public static func register(with registrar: FlutterPluginRegistrar) {
let plugin = URLLauncherPlugin()
let plugin = URLLauncherPlugin(
viewPresenterProvider: DefaultViewPresenterProvider(registrar: registrar))
UrlLauncherApiSetup.setUp(binaryMessenger: registrar.messenger(), api: plugin)
registrar.publish(plugin)
}

private var currentSession: URLLaunchSession?
private let launcher: Launcher
/// The view presenter provider, for showing a Safari view controller.
private let viewPresenterProvider: ViewPresenterProvider
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The view controller provider

Should we call this a ViewControllerPresenterProvider then?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to "The view presenter provider". Because it's just a testability abstraction around a view controller it was hard not to default to calling it a view controller.


private var topViewController: UIViewController? {
// TODO(stuartmorgan) Provide a non-deprecated codepath. See
// https://github.com/flutter/flutter/issues/104117
UIApplication.shared.keyWindow?.rootViewController?.topViewController
}

init(launcher: Launcher = DefaultLauncher()) {
init(launcher: Launcher = DefaultLauncher(), viewPresenterProvider: ViewPresenterProvider) {
self.launcher = launcher
self.viewPresenterProvider = viewPresenterProvider
}

func canLaunchUrl(url: String) -> LaunchResult {
Expand Down Expand Up @@ -58,43 +56,21 @@ public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi {
return
}

guard let presenter = viewPresenterProvider.viewPresenter else {
completion(.success(.noUI))
return
}

let session = URLLaunchSession(url: url, completion: completion)
currentSession = session

session.didFinish = { [weak self] in
self?.currentSession = nil
}
topViewController?.present(session.safariViewController, animated: true, completion: nil)
presenter.present(session.safariViewController, animated: true, completion: nil)
}

func closeSafariViewController() {
currentSession?.close()
}
}

/// This method recursively iterates through the view hierarchy
/// to return the top-most view controller.
///
/// It supports the following scenarios:
///
/// - The view controller is presenting another view.
/// - The view controller is a UINavigationController.
/// - The view controller is a UITabBarController.
///
/// @return The top most view controller.
extension UIViewController {
var topViewController: UIViewController {
if let navigationController = self as? UINavigationController {
return navigationController.viewControllers.last?.topViewController
?? navigationController
.visibleViewController ?? navigationController
}
if let tabBarController = self as? UITabBarController {
return tabBarController.selectedViewController?.topViewController ?? tabBarController
}
if let presented = presentedViewController {
return presented.topViewController
}
return self
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is copied directly from the recent file_selector_ios PR to adopt UIScene, since we have the same DI needs here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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.

import Flutter
import UIKit

/// Protocol for UIViewController methods relating to presenting a controller.
///
/// This protocol exists to allow injecting an alternate implementation for testing.
protocol ViewPresenter {
/// Presents a view controller modally.
func present(
_ viewControllerToPresent: UIViewController,
animated flag: Bool,
completion: (() -> Void)?
)
}

/// ViewPresenter is intentionally a direct passthroguh to UIViewController.
extension UIViewController: ViewPresenter {}

/// Protocol for FlutterPluginRegistrar method for accessing the view controller.
///
/// This is necessary because Swift doesn't allow for only partially implementing a protocol, so
/// a stub implementation of FlutterPluginRegistrar for tests would break any time something was
/// added to that protocol.
protocol ViewPresenterProvider {
/// Returns the view presenter associated with the Flutter content.
var viewPresenter: ViewPresenter? { get }
}

/// Non-test implementation of ViewPresenterProvider that forwards to the plugin registrar.
final class DefaultViewPresenterProvider: ViewPresenterProvider {
private let registrar: FlutterPluginRegistrar

init(registrar: FlutterPluginRegistrar) {
self.registrar = registrar
}

var viewPresenter: ViewPresenter? {
registrar.viewController
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ enum InAppLoadResult: Int {
case failedToLoad = 1
/// The URL could not be launched because it is invalid.
case invalidUrl = 2
/// The URL could not be launched because no UI is available.
case noUI = 3
/// The controller was closed before loading.
case dismissed = 3
case dismissed = 4
}

private class MessagesPigeonCodecReader: FlutterStandardReader {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ enum InAppLoadResult {
/// The URL could not be launched because it is invalid.
invalidUrl,

/// The URL could not be launched because no UI is available.
noUI,

/// The controller was closed before loading.
dismissed,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ class UrlLauncherIOS extends UrlLauncherPlatform {
throw _failedSafariViewControllerLoadException(url);
case InAppLoadResult.invalidUrl:
throw _invalidUrlException();
case InAppLoadResult.noUI:
throw _noUIException();
case InAppLoadResult.dismissed:
return false;
}
Expand Down Expand Up @@ -178,4 +180,16 @@ class UrlLauncherIOS extends UrlLauncherPlatform {
message: 'Error while launching $url',
);
}

// TODO(stuartmorgan): Remove this as part of standardizing error handling.
// See https://github.com/flutter/flutter/issues/127665
//
// This PlatformException is designed to match the pattern of the pre-existing
// exceptions above.
PlatformException _noUIException() {
throw PlatformException(
code: 'no_ui_available',
message: 'No view controller available',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ enum InAppLoadResult {
/// The URL could not be launched because it is invalid.
invalidUrl,

/// The URL could not be launched because no UI is available.
noUI,

/// The controller was closed before loading.
dismissed,
}
Expand Down
6 changes: 3 additions & 3 deletions packages/url_launcher/url_launcher_ios/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ name: url_launcher_ios
description: iOS implementation of the url_launcher plugin.
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_ios
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
version: 6.3.6
version: 6.4.0

environment:
sdk: ^3.9.0
flutter: ">=3.35.0"
sdk: ^3.10.0
flutter: ">=3.38.0"

flutter:
plugin:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,26 @@ void main() {
);
});

test('throws PlatformException for missing view controller', () async {
when(
api.openUrlInSafariViewController(_webUrl),
).thenAnswer((_) async => InAppLoadResult.noUI);
final launcher = UrlLauncherIOS(api: api);
await expectLater(
launcher.launchUrl(
_webUrl,
const LaunchOptions(mode: PreferredLaunchMode.inAppWebView),
),
throwsA(
isA<PlatformException>().having(
(PlatformException e) => e.code,
'code',
'no_ui_available',
),
),
);
});

test('throws PlatformException for load failure', () async {
when(
api.openUrlInSafariViewController(_webUrl),
Expand Down
4 changes: 0 additions & 4 deletions script/configs/exclude_xcode_deprecation.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,2 @@
# TODO(louisehsu): Remove deprecation check when StoreKit 2 is adopted. https://github.com/flutter/flutter/issues/116383
- in_app_purchase_storekit
# TODO(stuartmorgan): Remove once there's a non-deprecated codepath for getting
# the view controller available on stable.
# See https://github.com/flutter/flutter/issues/174415
- url_launcher_ios