diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3887ce65..525e0c01 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,7 +48,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Gradle cache - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: AVD cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: avd-cache @@ -75,9 +75,6 @@ jobs: melos-version: "^7.5.0" - name: "Bootstrap package" run: melos bootstrap --scope tests - # needed because twitter_login plugin doesn't have a namespace defined and he hasn't released a new version yet: https://github.com/0maru/twitter_login/issues/139 - - name: Patch twitter_login plugin - run: ./scripts/patch-twitter-login.sh - name: Start Firebase Emulator run: | cd functions/ diff --git a/docs/firebase-ui-auth/providers/oauth.md b/docs/firebase-ui-auth/providers/oauth.md index 665e7691..395dab9c 100644 --- a/docs/firebase-ui-auth/providers/oauth.md +++ b/docs/firebase-ui-auth/providers/oauth.md @@ -156,20 +156,27 @@ See [Custom screens section](#custom-screens) to learn how to use a button on yo ## Twitter Login -To support Twitter as a provider, first install the [`twitter_login`](https://pub.dev/packages/twitter_login) -plugin to your project and make sure you've performed necessary configuration as described on [README](https://pub.dev/packages/twitter_login). - -Next, enable the "Twitter" provider in the Firebase Console: +Enable the "Twitter" provider in the Firebase Console, and give it the API key and secret from your +app in the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps): ![Enable Twitter Provider](../images/ui-twitter-provider.jpg) -You will also need to install [`firebase_ui_oauth_twitter`](https://pub.dev/packages/firebase_ui_oauth_twitter): +![Twitter app id](../images/ui-twitter-app-id.png) + +Then set the "Callback URL" of that same X app to the Firebase auth handler, which the Firebase +Console shows when you enable the provider: + +``` +https://.firebaseapp.com/__/auth/handler +``` + +Install [`firebase_ui_oauth_twitter`](https://pub.dev/packages/firebase_ui_oauth_twitter): ```sh flutter pub add firebase_ui_oauth_twitter ``` -And add a provider to the configuration: +And add the provider to the configuration: ```dart Future main() async { @@ -177,32 +184,132 @@ Future main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); FirebaseUIAuth.configureProviders([ - TwitterProvider( - apiKey: TWITTER_API_KEY, - apiSecretKey: TWITTER_API_SECRET_KEY, - ), + TwitterProvider(), ]); } ``` Now all pre-built screens that support multiple providers (such as `RegisterScreen`, `SignInScreen`, `ProfileScreen` and others) will have a themed button. -You can get the `apiKey` and `apiSecretKey` from the Firebase Console or [twitter developer portal](https://developer.twitter.com/en/portal/projects-and-apps). +### Android and iOS setup -![Twitter app id](../images/ui-twitter-app-id.png) +On Android and iOS, Firebase performs the sign in itself, so the API key and secret stay in the +Firebase Console and are never shipped in your app. + +On **iOS**, add your encoded app ID as a URL scheme in `ios/Runner/Info.plist`. You will find it in +the Firebase Console under Project settings, listed as the App ID for your iOS app, with `:` +replaced by `-`: + +```xml +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + app-1-1234567890-ios-0a1b2c3d4e5f6g7h8i9j + + + +``` + +On **Android**, add your app's SHA-1 fingerprint in the Firebase Console under Project settings, so +Firebase can verify the sign in request. + +### macOS and Windows setup + +Firebase does not support this sign in flow on macOS or Windows, so on those platforms +`firebase_ui_oauth_twitter` performs the OAuth 1.0a flow itself and does need the API key and +secret: -Providing the `apiSecretKey` directly is not advised if you are building for the web. Instead, you can use "dart-define" to ensure that the value is omitted from web builds: +```dart +TwitterProvider( + apiKey: TWITTER_API_KEY, + apiSecretKey: TWITTER_API_SECRET_KEY, +), +``` + +They are ignored on Android, iOS and the web. On a platform that needs them, sign in fails with a +`FirebaseAuthException` rather than proceeding with empty credentials, so an absent +`--dart-define` surfaces as a clear error. + +Because the secret is embedded in desktop builds, pass it at build time rather than committing it: ```bash flutter run --dart-define TWITTER_SECRET= ``` -When building the app on platforms other than the web, the `TWITTER_SECRET` environment variable can be defined using: - ```dart -apiSecretKey: String.fromEnvironment('TWITTER_SECRET', ''), +apiSecretKey: String.fromEnvironment('TWITTER_SECRET'), +``` + +### Upgrading from 2.x + +Version 3.0.0 moves Twitter sign in on Android and iOS from the `twitter_login` package to +Firebase's own provider flow. Your code keeps compiling, but sign in fails at runtime on those two +platforms until you update the configuration below. macOS, Windows and the web are unaffected. + +#### What you must change + +1. **Callback URL.** In the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps), + set your app's Callback URL to the Firebase auth handler: + + ``` + https://.firebaseapp.com/__/auth/handler + ``` + + X accepts several callback URLs, so you can add this alongside the custom scheme you use today + and keep an older build of your app working while you roll out. + +2. **iOS.** Add your encoded app ID as a URL scheme in `ios/Runner/Info.plist`, as described in the + setup section above. Without it, the sign in sheet completes but never returns to your app. + +3. **Android.** Register your app's SHA-1 fingerprint in the Firebase Console, then **re-download + `google-services.json`**. Adding the fingerprint alone is not enough, because the certificate + hash is embedded in that file when you download it. If you skip either step, sign in fails with: + + ``` + There was an error while trying to get your package certificate hash. + ``` + +4. **Remove `twitter_login`** from your `pubspec.yaml` if you depended on it directly, along with + the callback intent filter it required in `AndroidManifest.xml`. + +5. **`apiKey` and `apiSecretKey` are now optional.** Remove them unless you ship for macOS or + Windows, which still perform the OAuth 1.0a flow in process and still need them. They are + ignored on Android, iOS and the web. + +#### Behaviour changes + +Firebase signs the user in as part of returning the credential, which changes three things on +Android and iOS: + +- `AuthAction.none` now fails with a `FirebaseAuthException` instead of handing you a credential + without signing in. There is no way to obtain the credential without also creating a session. +- The credential passed to `onCredentialLinked` is a plain `AuthCredential` rather than an + `OAuthCredential`. It carries no `secret` and cannot be cast to `OAuthCredential`. +- `redirectUri` is ignored. Firebase always completes through its own auth handler. It is still + honoured on macOS and Windows. + +Cancelling sign in returns you to the app silently, with no error shown, which matches the previous +behaviour. + +#### Known limitation on Android + +Firebase usually opens the sign in link in a Chrome Custom Tab inside your app's task, and +dismissing it returns to your app. Occasionally it opens the full browser in its own task instead. +If the user abandons the flow there, the pending operation never resolves and further attempts fail +with: + +``` +A headful operation is already in progress. Please wait for that to finish. ``` +Restarting the app clears it. This comes from the Firebase Android SDK rather than +`firebase_ui_oauth_twitter`, and there is nothing the Dart layer can do about a sign in the SDK +never completes. + See [Custom screens section](#custom-screens) to learn how to use a button on your custom screen. ## Custom screens diff --git a/packages/firebase_ui_auth/example/ios/Runner/Info.plist b/packages/firebase_ui_auth/example/ios/Runner/Info.plist index a2b5c4fc..891a4417 100644 --- a/packages/firebase_ui_auth/example/ios/Runner/Info.plist +++ b/packages/firebase_ui_auth/example/ios/Runner/Info.plist @@ -40,6 +40,14 @@ fb128693022464535 + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + app-1-406099696497-ios-24bb8dcaefc434a73574d0 + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) diff --git a/packages/firebase_ui_auth/example/lib/config.dart b/packages/firebase_ui_auth/example/lib/config.dart index 37ed2221..3553e75b 100644 --- a/packages/firebase_ui_auth/example/lib/config.dart +++ b/packages/firebase_ui_auth/example/lib/config.dart @@ -23,6 +23,5 @@ const GOOGLE_REDIRECT_URI = const TWITTER_API_KEY = String.fromEnvironment('TWITTER_API_KEY'); const TWITTER_API_SECRET_KEY = String.fromEnvironment('TWITTER_API_SECRET_KEY'); -const TWITTER_REDIRECT_URI = 'ffire://'; const FACEBOOK_CLIENT_ID = '128693022464535'; diff --git a/packages/firebase_ui_auth/example/lib/main.dart b/packages/firebase_ui_auth/example/lib/main.dart index ebcb22ca..1fae4426 100644 --- a/packages/firebase_ui_auth/example/lib/main.dart +++ b/packages/firebase_ui_auth/example/lib/main.dart @@ -43,10 +43,11 @@ Future main() async { GoogleProvider(clientId: GOOGLE_CLIENT_ID), AppleProvider(), FacebookProvider(clientId: FACEBOOK_CLIENT_ID), + // apiKey and apiSecretKey are only used on macOS and Windows. Android and + // iOS sign in through Firebase, which holds the credentials itself. TwitterProvider( apiKey: TWITTER_API_KEY, apiSecretKey: TWITTER_API_SECRET_KEY, - redirectUri: TWITTER_REDIRECT_URI, ), ]); @@ -134,10 +135,16 @@ class FirebaseAuthUIExample extends StatelessWidget { }; switch (user) { - case User(emailVerified: true): - Navigator.pushReplacementNamed(context, '/profile'); case User(emailVerified: false, email: final String _): Navigator.pushNamed(context, '/verify-email'); + // Providers are not obliged to return an email. Twitter only + // does when the app requests it, and such a user is never + // emailVerified, so without this they matched no case and + // the screen sat there after a successful sign in. + case User(): + Navigator.pushReplacementNamed(context, '/profile'); + case null: + break; } }), mfaAction, diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index b1f47897..618a45d1 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -38,8 +38,6 @@ dependencies: firebase_ui_oauth_facebook: ^2.1.1 firebase_ui_oauth_google: ^2.1.1 firebase_ui_oauth_twitter: ^2.1.1 - # This and twitter oauth package need to depend on git main directly due to namespace build error on android. - twitter_login: ^4.4.2 dev_dependencies: drive: ^1.0.0-1.0.nullsafety.5 firebase_ui_shared: ^1.5.0 diff --git a/packages/firebase_ui_auth/lib/src/email_verification.dart b/packages/firebase_ui_auth/lib/src/email_verification.dart index 731de045..15c8452d 100644 --- a/packages/firebase_ui_auth/lib/src/email_verification.dart +++ b/packages/firebase_ui_auth/lib/src/email_verification.dart @@ -90,7 +90,14 @@ class EmailVerificationController extends ValueNotifier } /// Reloads firebase user and updates the [state]. + /// + /// Does nothing when no user is signed in. This runs on every app resume, + /// which includes resuming with no user at all: returning from a cancelled + /// OAuth sign in brings the app back to the foreground while + /// [fba.FirebaseAuth.currentUser] is still null. Future reload() async { + if (auth.currentUser == null) return; + await user.reload(); if (user.email == null) { diff --git a/packages/firebase_ui_auth/test/email_verification_test.dart b/packages/firebase_ui_auth/test/email_verification_test.dart new file mode 100644 index 00000000..69219b97 --- /dev/null +++ b/packages/firebase_ui_auth/test/email_verification_test.dart @@ -0,0 +1,29 @@ +// Copyright 2022, 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_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +void main() { + // The controller registers a lifecycle observer in its constructor. + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EmailVerificationController', () { + test('reload does nothing when no user is signed in', () async { + final auth = MockAuth(); + final controller = EmailVerificationController( + auth, + appLinks: MockAppLinks(), + ); + + // reload() runs on every app resume, including resumes that happen with + // no user at all, such as returning from a cancelled OAuth sign in. + // It used to read auth.currentUser! and throw an unhandled TypeError. + await expectLater(controller.reload(), completes); + expect(controller.state, EmailVerificationState.unresolved); + }); + }); +} diff --git a/packages/firebase_ui_oauth/example/lib/main.dart b/packages/firebase_ui_oauth/example/lib/main.dart index e7cd367f..a8bffe72 100644 --- a/packages/firebase_ui_oauth/example/lib/main.dart +++ b/packages/firebase_ui_oauth/example/lib/main.dart @@ -108,10 +108,7 @@ class _ContentState extends State { GoogleProvider(clientId: '', redirectUri: '', scopes: []), 'Sign in with Google', ), - _button( - TwitterProvider(apiKey: '', apiSecretKey: '', redirectUri: ''), - 'Sign in with Twitter', - ), + _button(TwitterProvider(), 'Sign in with Twitter'), ], ), ), diff --git a/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart b/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart index 3b9bbb5a..36f894b2 100644 --- a/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart +++ b/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart @@ -15,8 +15,8 @@ class TwitterSignInButton extends _TwitterSignInButton { const TwitterSignInButton({ super.key, required super.loadingIndicator, - required super.apiKey, - required super.apiSecretKey, + super.apiKey, + super.apiSecretKey, super.redirectUri, super.action = null, super.auth, @@ -35,8 +35,8 @@ class TwitterSignInButton extends _TwitterSignInButton { class TwitterSignInIconButton extends _TwitterSignInButton { const TwitterSignInIconButton({ super.key, - required super.apiKey, - required super.apiSecretKey, + super.apiKey, + super.apiSecretKey, required super.loadingIndicator, super.action = null, super.auth, @@ -74,16 +74,16 @@ class _TwitterSignInButton extends StatelessWidget { final DifferentProvidersFoundCallback? onDifferentProvidersFound; final SignedInCallback? onSignedIn; final double size; - final String apiKey; - final String apiSecretKey; + final String? apiKey; + final String? apiSecretKey; final String? redirectUri; final void Function(Exception exception)? onError; final VoidCallback? onCanceled; const _TwitterSignInButton({ super.key, - required this.apiKey, - required this.apiSecretKey, + this.apiKey, + this.apiSecretKey, required this.loadingIndicator, String? label, bool? overrideDefaultTapAction, diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 10d5420c..d4356326 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -5,65 +5,210 @@ import 'package:firebase_auth/firebase_auth.dart' hide OAuthProvider; import 'package:flutter/foundation.dart'; import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; -import 'package:twitter_login/twitter_login.dart'; import 'theme.dart'; +/// A Firebase UI Auth provider which should be used to add Twitter Sign In +/// to your app. +/// +/// On Android and iOS the sign in flow is handled by Firebase itself via +/// [FirebaseAuth.signInWithProvider], so [apiKey] and [apiSecretKey] are not +/// needed: configure the Twitter provider in the Firebase console instead. +/// +/// macOS and Windows still perform the OAuth 1.0a flow in-process and do +/// require [apiKey] and [apiSecretKey]. `signInWithProvider` rejects every +/// provider except Apple on macOS, so the desktop flow stays in place there. class TwitterProvider extends OAuthProvider { @override final providerId = 'twitter.com'; - final String apiKey; - final String apiSecretKey; + + /// The Twitter API key. + /// + /// Only required on macOS and Windows, which perform the OAuth 1.0a flow + /// in-process. + final String? apiKey; + + /// The Twitter API secret key. + /// + /// Only required on macOS and Windows, which perform the OAuth 1.0a flow + /// in-process. + final String? apiSecretKey; + final String? redirectUri; @override final style = const TwitterProviderButtonStyle(); @override - late final desktopSignInArgs = TwitterSignInArgs( - apiKey: apiKey, - apiSecretKey: apiSecretKey, - redirectUri: redirectUri ?? defaultRedirectUri, - ); - - late TwitterLogin provider = TwitterLogin( - apiKey: apiKey, - apiSecretKey: apiSecretKey, - redirectURI: redirectUri ?? defaultRedirectUri, - ); - - TwitterProvider({ - required this.apiKey, - required this.apiSecretKey, - this.redirectUri, - }); + TwitterAuthProvider firebaseAuthProvider = TwitterAuthProvider(); + + /// Whether both OAuth 1.0a credentials are usable. + /// + /// `String.fromEnvironment` yields an empty string rather than null when the + /// define is absent, and that is the documented way to supply these, so an + /// empty value has to count as missing too. + bool get _hasDesktopCredentials => + (apiKey?.isNotEmpty ?? false) && (apiSecretKey?.isNotEmpty ?? false); + + @override + TwitterSignInArgs get desktopSignInArgs { + final apiKey = this.apiKey; + final apiSecretKey = this.apiSecretKey; + + if (apiKey == null || + apiKey.isEmpty || + apiSecretKey == null || + apiSecretKey.isEmpty) { + throw ArgumentError( + 'TwitterProvider.apiKey and TwitterProvider.apiSecretKey are required ' + 'on $defaultTargetPlatform, which signs in using the OAuth 1.0a flow. ' + 'Android and iOS use the Firebase native provider flow and do not ' + 'need them.', + ); + } + + return TwitterSignInArgs( + apiKey: apiKey, + apiSecretKey: apiSecretKey, + redirectUri: redirectUri ?? defaultRedirectUri, + ); + } + + TwitterProvider({this.apiKey, this.apiSecretKey, this.redirectUri}); + + bool _warnedAboutIgnoredKeys = false; + + /// Warns once, in debug builds, that [apiKey] and [apiSecretKey] no longer + /// take part in sign in on the platforms that use the Firebase provider + /// flow. Without this the change is silent: the app still compiles, and the + /// first signal the developer gets is a sign in that fails in the browser. + void _warnIfKeysAreIgnored() { + if (!kDebugMode || _warnedAboutIgnoredKeys) return; + if (!(apiKey?.isNotEmpty ?? false) && + !(apiSecretKey?.isNotEmpty ?? false)) { + return; + } + + _warnedAboutIgnoredKeys = true; + + debugPrint( + 'TwitterProvider: apiKey and apiSecretKey are ignored on ' + '$defaultTargetPlatform. Sign in is now performed by Firebase, which ' + 'reads the Twitter API key and secret from the Firebase console. They ' + 'are still used on macOS and Windows.\n' + 'If sign in fails, check that the Twitter app callback URL is ' + '"$defaultRedirectUri", and that you have added the ' + 'Encoded App ID URL scheme (iOS) or your SHA-1 fingerprint ' + '(Android). See ' + 'https://github.com/firebase/FirebaseUI-Flutter/blob/main/docs/firebase-ui-auth/providers/oauth.md#twitter-login', + ); + } + + /// Whether [error] is the user dismissing the sign in sheet. + /// + /// The native SDKs surface this as a `FirebaseAuthException` rather than a + /// cancellation, so it has to be recognised by code. Android reports the + /// underlying `ERROR_`-prefixed constant while Apple platforms report the + /// hyphenated form, and both spellings of "cancelled" are in use. + bool _isUserCancellation(Object error) { + if (error is! FirebaseAuthException) return false; + + var code = error.code.toLowerCase().replaceAll('_', '-'); + if (code.startsWith('error-')) code = code.substring('error-'.length); + + return const { + 'web-context-cancelled', + 'web-context-canceled', + 'user-cancelled', + 'user-canceled', + }.contains(code); + } + + void _onError(Object error) { + if (_isUserCancellation(error)) { + authListener.onCanceled(); + return; + } + + authListener.onError(error); + } @override void mobileSignIn(AuthAction action) { - final result = provider.login(); - - result - .then((value) { - switch (value.status!) { - case TwitterLoginStatus.loggedIn: - final credential = TwitterAuthProvider.credential( - accessToken: value.authToken!, - secret: value.authTokenSecret!, - ); - - onCredentialReceived(credential, action); - break; - case TwitterLoginStatus.cancelledByUser: - authListener.onError(AuthCancelledException()); - break; - case TwitterLoginStatus.error: - authListener.onError(Exception(value.errorMessage)); - break; - } - }) - .catchError((err) { - authListener.onError(err); - }); + if (action == AuthAction.none) { + // Reported rather than thrown: signIn() has already moved the flow into + // its loading state, and an Error raised here would escape both + // AuthFlow.onError and the button's handler, which catch only Exception, + // leaving the button spinning forever. + authListener.onError( + FirebaseAuthException( + code: 'unsupported-auth-action', + message: + 'AuthAction.none is not supported by TwitterProvider on ' + '$defaultTargetPlatform. Firebase signs the user in as part of ' + 'obtaining the credential, so the credential cannot be returned ' + 'without also creating a session.', + ), + ); + return; + } + + _warnIfKeysAreIgnored(); + + // Linking is also used to upgrade an anonymous user, so that the + // anonymous uid survives the sign in. + if (action == AuthAction.link || shouldUpgradeAnonymous) { + final currentUser = auth.currentUser; + + // Only AuthAction.link can reach this with no user, since + // shouldUpgradeAnonymous is false when currentUser is null. Reporting it + // matters because a null-shorting call would leave the flow stuck in its + // loading state with no error and no completion. + if (currentUser == null) { + authListener.onError( + FirebaseAuthException( + code: 'no-current-user', + message: + 'AuthAction.link requires a signed in user to link the ' + 'Twitter credential to, but FirebaseAuth.currentUser is null.', + ), + ); + return; + } + + currentUser + .linkWithProvider(firebaseAuthProvider) + .then(_onLinked) + .catchError(_onError); + return; + } + + auth + .signInWithProvider(firebaseAuthProvider) + .then(authListener.onSignedIn) + .catchError(_onError); + } + + @override + void desktopSignIn(AuthAction action) { + // desktopSignInArgs is read synchronously by the desktop flow, outside any + // error handling, so a throw there would escape as an Error and hang the + // UI. Check first and report through the listener instead. + if (!_hasDesktopCredentials) { + authListener.onError( + FirebaseAuthException( + code: 'missing-oauth-credentials', + message: + 'TwitterProvider.apiKey and TwitterProvider.apiSecretKey are ' + 'required on $defaultTargetPlatform, which signs in using the ' + 'OAuth 1.0a flow. Android and iOS use the Firebase native ' + 'provider flow and do not need them.', + ), + ); + return; + } + + super.desktopSignIn(action); } @override @@ -74,9 +219,6 @@ class TwitterProvider extends OAuthProvider { ); } - @override - TwitterAuthProvider get firebaseAuthProvider => TwitterAuthProvider(); - @override Future logOutProvider() { return SynchronousFuture(null); @@ -86,4 +228,24 @@ class TwitterProvider extends OAuthProvider { bool supportsPlatform(TargetPlatform platform) { return true; } + + void _onLinked(UserCredential userCredential) { + final credential = userCredential.credential; + + // Nullable on every platform, and a force unwrap here would throw inside + // .then, reaching onError as an Error and hanging the flow. + if (credential == null) { + authListener.onError( + FirebaseAuthException( + code: 'missing-credential', + message: + 'The Twitter account was linked, but Firebase returned no ' + 'credential for it.', + ), + ); + return; + } + + authListener.onCredentialLinked(credential); + } } diff --git a/packages/firebase_ui_oauth_twitter/pubspec.yaml b/packages/firebase_ui_oauth_twitter/pubspec.yaml index fefdf7d9..d27ce0c4 100644 --- a/packages/firebase_ui_oauth_twitter/pubspec.yaml +++ b/packages/firebase_ui_oauth_twitter/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: sdk: flutter firebase_auth: ^6.5.4 firebase_ui_oauth: ^2.1.1 - twitter_login: ^4.4.2 dev_dependencies: flutter_test: diff --git a/scripts/patch-twitter-login.sh b/scripts/patch-twitter-login.sh deleted file mode 100755 index 3acb2c79..00000000 --- a/scripts/patch-twitter-login.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -set -e - -# Script to patch twitter_login plugin to add namespace for AGP 8.x compatibility -# This is required because twitter_login 4.4.2 doesn't have a namespace defined - -echo "Patching twitter_login plugin for AGP 8.x compatibility..." - -# Determine pub cache directory (supports both local and CI environments) -if [ -n "$PUB_CACHE" ]; then - PUB_CACHE_DIR="$PUB_CACHE" -elif [ -n "$FLUTTER_ROOT" ]; then - PUB_CACHE_DIR="$FLUTTER_ROOT/.pub-cache" -else - PUB_CACHE_DIR="$HOME/.pub-cache" -fi - -echo "Using pub cache directory: $PUB_CACHE_DIR" - -# Find the twitter_login plugin build.gradle file (not the example one) -TWITTER_LOGIN_BUILD_GRADLE=$(find "$PUB_CACHE_DIR/hosted" -name "build.gradle" -path "*/twitter_login-*/android/build.gradle" ! -path "*/example/*" 2>/dev/null | head -n 1) - -if [ -z "$TWITTER_LOGIN_BUILD_GRADLE" ]; then - echo "Error: Could not find twitter_login build.gradle file" - echo "Searched in: $PUB_CACHE_DIR/hosted" - echo "Available twitter_login directories:" - find "$PUB_CACHE_DIR/hosted" -type d -name "twitter_login-*" 2>/dev/null || echo "None found" - exit 1 -fi - -echo "Found twitter_login build.gradle at: $TWITTER_LOGIN_BUILD_GRADLE" - -# Check if namespace is already present -if grep -q "namespace" "$TWITTER_LOGIN_BUILD_GRADLE"; then - echo "Namespace already present in twitter_login build.gradle, skipping patch" - exit 0 -fi - -# Add namespace to android block -# Use different sed syntax for macOS vs Linux -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i.bak '/^android {$/a\ - namespace '\''com.maru.twitter_login'\'' -' "$TWITTER_LOGIN_BUILD_GRADLE" -else - # Linux - sed -i '/^android {$/a\ namespace '\''com.maru.twitter_login'\''' "$TWITTER_LOGIN_BUILD_GRADLE" -fi - -echo "Successfully patched twitter_login build.gradle with namespace" diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index 326533d2..b495ca5b 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -8,8 +8,7 @@ plugins { android { namespace = "io.flutter.plugins.firebase.tests" - // use "flutter.compileSdkVersion" and bump AGP once twitter_login has released v4.4.3: https://github.com/0maru/twitter_login/issues/139 - compileSdk 36 + compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { diff --git a/tests/android/gradle/wrapper/gradle-wrapper.properties b/tests/android/gradle/wrapper/gradle-wrapper.properties index 6cb8454c..efdcc4ac 100644 --- a/tests/android/gradle/wrapper/gradle-wrapper.properties +++ b/tests/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/tests/android/settings.gradle b/tests/android/settings.gradle index c211ea54..7d38085a 100644 --- a/tests/android/settings.gradle +++ b/tests/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.3" apply false + id "com.android.application" version "8.9.1" apply false id "org.jetbrains.kotlin.android" version "2.3.0" apply false id "com.google.gms.google-services" version "4.4.2" apply false } diff --git a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart index da5424dd..1383af6a 100644 --- a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart @@ -2,6 +2,8 @@ // 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_auth/firebase_auth.dart' as fba; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,53 +12,54 @@ import 'package:firebase_ui_localizations/firebase_ui_localizations.dart'; import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; import 'package:firebase_ui_oauth_twitter/firebase_ui_oauth_twitter.dart'; import 'package:mockito/mockito.dart'; -import 'package:twitter_login/twitter_login.dart'; -import 'package:twitter_login/entity/auth_result.dart' as twe; import '../utils.dart'; void main() async { - late TwitterProvider provider = TwitterProvider( - apiKey: 'apiKey', - apiSecretKey: 'apiSecretKey', - ); + final provider = TwitterProvider(); + late MockAuth auth; + late MockProvider fbProvider; + + const labels = DefaultLocalizations(); setUp(() { - provider.provider = MockTwitterLogin(); + auth = MockAuth(); + fbProvider = MockProvider(); + provider.firebaseAuthProvider = fbProvider; setMockTwitterProvider(provider); }); - const labels = DefaultLocalizations(); - group( 'Sign in with Twitter button', () { testWidgets('has a correct button label', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); expect(find.text(labels.signInWithTwitterButtonText), findsOneWidget); }); testWidgets('calls sign in when tapped', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); await tester.pumpAndSettle(); - verify(provider.provider.login()).called(1); - - expect(true, isTrue); + verify(auth.signInWithProvider(fbProvider)).called(1); }); testWidgets('shows loading indicator when sign in is in progress', ( tester, ) async { - await render(tester, OAuthProviderButton(provider: provider)); - - when(provider.provider.login()).thenAnswer((realInvocation) async { - await Future.delayed(const Duration(milliseconds: 50)); - return MockAuthResult(); - }); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); @@ -66,48 +69,249 @@ void main() async { }); testWidgets('signs the user in', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); await tester.pumpAndSettle(); - final user = auth.currentUser!; + final result = verify(listener.call(captureAny)); + expect(result.captured[1], isA()); + final user = (result.captured[1] as SignedIn).user!; expect(user.displayName, 'Test User'); expect(user.email, 'test@test.com'); }); + + testWidgets('links the credential when the user is anonymous', ( + tester, + ) async { + final anonymousUser = MockAnonymousUser(); + auth.currentUserOverride = anonymousUser; + + // AuthAction.signIn is explicit: the flow would otherwise resolve to + // AuthAction.link on its own whenever currentUser is non-null, and the + // test would pass without shouldUpgradeAnonymous being consulted. + await render( + tester, + OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.signIn, + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + verify(anonymousUser.linkWithProvider(fbProvider)).called(1); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('reports an error when linking with no signed in user', ( + tester, + ) async { + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.link, + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('reports an error rather than hanging for AuthAction.none', ( + tester, + ) async { + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.none, + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + expect(find.byType(CircularProgressIndicator), findsNothing); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('resets the flow when the user cancels', (tester) async { + auth.signInErrorOverride = fba.FirebaseAuthException( + code: 'web-context-cancelled', + ); + + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isNot(isA())); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets( + 'reports an error rather than hanging when desktop keys are missing', + (tester) async { + final listener = MockListener(); + + await render( + tester, + Theme( + data: ThemeData(platform: TargetPlatform.macOS), + child: AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + }, + ); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); + + group('TwitterProvider', () { + test('throws from desktopSignInArgs when the API keys are missing', () { + expect(() => TwitterProvider().desktopSignInArgs, throwsArgumentError); + }); + }); +} + +class MockListener extends Mock { + void call(AuthState? state) { + super.noSuchMethod(Invocation.method(#call, [state])); + } } -// Mock JWT with the following payload: -// { -// "sub": "1234567890", -// "name": "Test User", -// "email": "test@test.com", -// "iat": 1516239022 -// } -const _jwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTUxNjIzOTAyMn0.m5qYto_Vs5ELTURC8rkD-JAJuoosdQZeuUZ_qFrEiaE'; - -class MockAuthResult extends Mock implements twe.AuthResult { +class MockUser extends Mock implements fba.User { @override - TwitterLoginStatus? get status => TwitterLoginStatus.loggedIn; + String? get displayName => 'Test User'; + @override - String? get authToken => _jwt; + String? get email => 'test@test.com'; + @override - String? get authTokenSecret => 'secret'; + bool get isAnonymous => false; } -class MockTwitterLogin extends Mock implements TwitterLogin { +class MockAnonymousUser extends Mock implements fba.User { @override - Future login({bool? forceLogin}) async { + bool get isAnonymous => true; + + @override + Future linkWithProvider(Object provider) async { + return super.noSuchMethod( + Invocation.method(#linkWithProvider, [provider]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } +} + +class MockAuthCredential extends Mock implements fba.AuthCredential {} + +class MockCredential extends Mock implements fba.UserCredential { + @override + fba.User? get user => MockUser(); + + @override + fba.AuthCredential? get credential => MockAuthCredential(); +} + +class MockProvider extends Mock implements fba.TwitterAuthProvider {} + +class MockApp extends Mock implements FirebaseApp {} + +class MockAuth extends Mock implements fba.FirebaseAuth { + fba.User? currentUserOverride; + Object? signInErrorOverride; + + @override + fba.User? get currentUser => currentUserOverride; + + @override + Future signInWithProvider(Object provider) async { + final error = signInErrorOverride; + if (error != null) { + await Future.delayed(const Duration(milliseconds: 50)); + throw error; + } + return super.noSuchMethod( - Invocation.method(#signIn, []), - returnValue: MockAuthResult(), - returnValueForMissingStub: MockAuthResult(), + Invocation.method(#signInWithProvider, [provider]), + returnValue: Future.delayed( + const Duration(milliseconds: 500), + ).then((_) => MockCredential()), + returnValueForMissingStub: Future.delayed( + const Duration(milliseconds: 500), + ).then((_) => MockCredential()), ); } } diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index e8b94245..ff1fd576 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: firebase_ui_oauth_google: ^2.1.1 firebase_ui_oauth: ^2.1.1 flutter_facebook_auth: ^7.1.2 - twitter_login: ^4.4.2 firebase_ui_oauth_twitter: ^2.1.1 cloud_firestore: 6.9.0 firebase_ui_firestore: ^2.1.0