镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

/// Implemented by `example/ios/Runner/AuthCallbackURLTestChannel.m`.
const _channel = MethodChannel('tests/auth_callback_url');

/// Records the URLs Flutter deep linking pushes to the app.
class _DeepLinkRecorder with WidgetsBindingObserver {
final List<Uri> uris = [];

@override
Future<bool> didPushRouteInformation(RouteInformation routeInformation) {
uris.add(routeInformation.uri);
return SynchronousFuture(true);
}

/// Completes once [uri] has been pushed.
Future<void> waitFor(Uri uri) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (!uris.contains(uri)) {
if (DateTime.now().isAfter(deadline)) {
fail('$uri was never pushed. Pushed: $uris');
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
}

/// The scheme the native SDK redirects with when the reversed client ID is not
/// registered.
String _appIdScheme(FirebaseOptions options) =>
'app-${options.appId.replaceAll(':', '-')}';

/// The scheme the native SDK redirects with when the reversed client ID is
/// registered, as the example's Info.plist does.
String? _reversedClientIdScheme(FirebaseOptions options) =>
options.iosClientId?.split('.').reversed.join('.');

/// What the reCAPTCHA, MFA and OAuth redirects open the app with.
Uri _authCallbackUri(String scheme) => Uri.parse(
'$scheme://firebaseauth/link?deep_link_id='
'${Uri.encodeQueryComponent('https://flutterfire-e2e-tests.firebaseapp.com/__/auth/callback?authType=signInViaRedirect&eventId=1234')}',
);

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group(
'Auth callback URLs',
() {
late _DeepLinkRecorder deepLinks;
var controlCount = 0;

setUp(() {
deepLinks = _DeepLinkRecorder();
WidgetsBinding.instance.addObserver(deepLinks);
});

tearDown(() {
WidgetsBinding.instance.removeObserver(deepLinks);
});

/// Opens a URL that deep linking must still receive, then checks it is
/// the only URL pushed.
///
/// The engine does not promise to push deep links in the order they were
/// opened, so an Auth URL opened earlier may still be on its way when the
/// control URL arrives. Waiting a little longer gives it the time to
/// arrive and fail the test.
Future<void> expectOnlyControlDeepLinkArrives() async {
final control = Uri.parse(
'${_appIdScheme(Firebase.app().options)}://example/control/'
'${controlCount++}',
);
await _channel.invokeMethod('openThroughScene', {
'url': control.toString(),
});
await deepLinks.waitFor(control);
await Future<void>.delayed(const Duration(seconds: 1));
expect(deepLinks.uris, [control]);
}

final schemes = <String, String? Function(FirebaseOptions)>{
'app ID': _appIdScheme,
'reversed client ID': _reversedClientIdScheme,
};

for (final MapEntry(key: name, value: schemeFor) in schemes.entries) {
group('with the $name scheme', () {
/// The callback URL for the [DEFAULT] app, or null after marking the
/// test skipped when its options have no such scheme.
Uri? authCallbackUri() {
final scheme = schemeFor(Firebase.app().options);
if (scheme == null) {
markTestSkipped(
'The [DEFAULT] app has no iOS client ID, so the SDK has no '
'reversed client ID scheme to redirect with.',
);
return null;
}
return _authCallbackUri(scheme);
}

testWidgets(
'opened through the app delegate are claimed by the plugin',
(tester) async {
final uri = authCallbackUri();
if (uri == null) return;
// Deep links wait for the first frame.
await tester.pumpWidget(const SizedBox());

final handled = await _channel.invokeMethod<bool>(
'openThroughAppDelegate',
{'url': uri.toString()},
);

// Only `handled` carries this test. The example uses UIScene, so
// FlutterAppDelegate has no root view controller and never
// pushes what it opens, claimed or not: the deep link check
// below cannot fail here.
expect(handled, isTrue);
await expectOnlyControlDeepLinkArrives();
},
);

testWidgets(
'opened through the scene do not reach deep linking',
(tester) async {
final uri = authCallbackUri();
if (uri == null) return;
await tester.pumpWidget(const SizedBox());

await _channel.invokeMethod('openThroughScene', {
'url': uri.toString(),
});

await expectOnlyControlDeepLinkArrives();
},
);
});
}

group('when there is no [DEFAULT] app', () {
late FirebaseApp namedApp;
late Uri authCallbackUri;

setUp(() async {
// A client ID only this app has, so the URL is matched through it.
namedApp = await Firebase.initializeApp(
name: 'auth-callback-url-test',
options: Firebase.app().options.copyWith(
iosClientId:
'123456789012-authcallbackurltest.apps.googleusercontent.com',
),
);
authCallbackUri =
_authCallbackUri(_reversedClientIdScheme(namedApp.options)!);
});

tearDown(() async {
await namedApp.delete();
});

// The channel makes `FirebaseApp.app()` return nil while it opens the
// URL, as in an app that only calls `Firebase.initializeApp(name:)`.
testWidgets(
'opened through the app delegate are claimed for a named app',
(tester) async {
await tester.pumpWidget(const SizedBox());

final handled = await _channel.invokeMethod<bool>(
'openThroughAppDelegate',
{'url': authCallbackUri.toString(), 'withoutDefaultApp': true},
);

expect(handled, isTrue);
},
);

testWidgets(
'opened through the scene do not reach deep linking for a named app',
(tester) async {
await tester.pumpWidget(const SizedBox());

await _channel.invokeMethod('openThroughScene', {
'url': authCallbackUri.toString(),
'withoutDefaultApp': true,
});

await expectOnlyControlDeepLinkArrives();
},
);
});
},
skip: kIsWeb || defaultTargetPlatform != TargetPlatform.iOS,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:firebase_auth_example/firebase_options.dart';

import 'auth_callback_url_e2e_test.dart' as auth_callback_url_tests;
import 'auth_emulator_web_e2e_test.dart' as auth_emulator_web_tests;
import 'firebase_auth_instance_e2e_test.dart' as instance_tests;
import 'firebase_auth_multi_factor_e2e_test.dart' as multi_factor_tests;
Expand Down Expand Up @@ -83,5 +84,6 @@ void main() {
user_tests.main();
multi_factor_tests.main();
auth_emulator_web_tests.main();
auth_callback_url_tests.main();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
3DBDE9876E1D48FC8ED096A3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E010D242A20C21968D02B7C9 /* Pods_Runner.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
A1C0FFEE2E7A11D000C0FFEE /* AuthCallbackURLTestChannel.m in Sources */ = {isa = PBXBuildFile; fileRef = A1C0FFEE2E7A11D200C0FFEE /* AuthCallbackURLTestChannel.m */; };
97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
Expand Down Expand Up @@ -43,6 +44,8 @@
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
A1C0FFEE2E7A11D100C0FFEE /* AuthCallbackURLTestChannel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AuthCallbackURLTestChannel.h; sourceTree = "<group>"; };
A1C0FFEE2E7A11D200C0FFEE /* AuthCallbackURLTestChannel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AuthCallbackURLTestChannel.m; sourceTree = "<group>"; };
7B49E087B951778510D20936 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -107,6 +110,8 @@
253804AE278DB662003BA2E2 /* Runner.entitlements */,
7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */,
7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */,
A1C0FFEE2E7A11D100C0FFEE /* AuthCallbackURLTestChannel.h */,
A1C0FFEE2E7A11D200C0FFEE /* AuthCallbackURLTestChannel.m */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -376,6 +381,7 @@
buildActionMask = 2147483647;
files = (
978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */,
A1C0FFEE2E7A11D000C0FFEE /* AuthCallbackURLTestChannel.m in Sources */,
97C146F31CF9000F007C117D /* main.m in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#import "AppDelegate.h"
#import "AuthCallbackURLTestChannel.h"
#import "GeneratedPluginRegistrant.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[GeneratedPluginRegistrant registerWithRegistry:self];
// Only used by integration_test/e2e_test.dart.
[AuthCallbackURLTestChannel
registerWithRegistrar:[self registrarForPlugin:@"AuthCallbackURLTestChannel"]];
// Override point for customization after application launch.
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

#import <Flutter/Flutter.h>

/// Test-only channel used by `integration_test/e2e_test.dart`.
///
/// Opens a URL through the app delegate or the scene delegate the same way iOS
/// does when a sign-in redirect returns to the app, without a browser.
@interface AuthCallbackURLTestChannel : NSObject

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar;

@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

#import "AuthCallbackURLTestChannel.h"

#import <objc/runtime.h>

@import FirebaseCore;

/// A URL context carrying a chosen URL, as UIKit passes to
/// `scene:openURLContexts:`.
@interface AuthCallbackURLTestContext : UIOpenURLContext
@end

@implementation AuthCallbackURLTestContext {
NSURL *_testURL;
}

+ (instancetype)contextWithURL:(NSURL *)url {
// UIKit marks -init unavailable; NSObject's -init would do nothing anyway.
AuthCallbackURLTestContext *context = [self alloc];
context->_testURL = url;
return context;
}

- (NSURL *)URL {
return _testURL;
}

- (UISceneOpenURLOptions *)options {
return nil;
}

@end

@implementation AuthCallbackURLTestChannel

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
FlutterMethodChannel *channel =
[FlutterMethodChannel methodChannelWithName:@"tests/auth_callback_url"
binaryMessenger:[registrar messenger]];
[channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
NSURL *url = [NSURL URLWithString:call.arguments[@"url"]];
BOOL withoutDefaultApp = [call.arguments[@"withoutDefaultApp"] boolValue];
if (url == nil) {
result([FlutterError errorWithCode:@"invalid-url" message:nil details:nil]);
} else if ([call.method isEqualToString:@"openThroughAppDelegate"]) {
__block BOOL handled;
[self withoutDefaultApp:withoutDefaultApp
run:^{
handled = [self openThroughAppDelegate:url];
}];
result(@(handled));
} else if ([call.method isEqualToString:@"openThroughScene"]) {
__block FlutterError *error;
[self withoutDefaultApp:withoutDefaultApp
run:^{
error = [self openThroughScene:url];
}];
result(error);
} else {
result(FlutterMethodNotImplemented);
}
}];
}

static FIRApp *AuthCallbackURLTestNoDefaultApp(id self, SEL _cmd) { return nil; }

/// Runs `block`. With `withoutDefaultApp`, `FirebaseApp.app()` returns nil
/// meanwhile, as in an app that only configures named apps.
///
/// [DEFAULT] stays configured. Deleting and configuring it again would replace
/// its native `Auth`, dropping the emulator settings and listeners the rest of
/// the suite relies on.
+ (void)withoutDefaultApp:(BOOL)withoutDefaultApp run:(void (^)(void))block {
if (!withoutDefaultApp) {
block();
return;
}
Method defaultApp = class_getClassMethod([FIRApp class], @selector(defaultApp));
IMP original = method_setImplementation(defaultApp, (IMP)AuthCallbackURLTestNoDefaultApp);
block();
method_setImplementation(defaultApp, original);
}

/// Returns what the app delegate returns to iOS.
+ (BOOL)openThroughAppDelegate:(NSURL *)url {
UIApplication *application = UIApplication.sharedApplication;
return [application.delegate application:application openURL:url options:@{}];
}

/// Returns nil once the scene delegate has been called, as
/// `scene:openURLContexts:` returns nothing.
+ (FlutterError *)openThroughScene:(NSURL *)url {
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
if ([scene.delegate respondsToSelector:@selector(scene:openURLContexts:)]) {
[scene.delegate scene:scene
openURLContexts:[NSSet setWithObject:[AuthCallbackURLTestContext contextWithURL:url]]];
return nil;
}
}
return [FlutterError errorWithCode:@"no-scene"
message:@"No connected scene opens URLs"
details:nil];
}

@end
Loading
Loading