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/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index 4d354e42..799fde10 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -39,7 +39,6 @@ dependencies: firebase_ui_oauth_google: ^2.1.0 firebase_ui_oauth_twitter: ^2.1.0 # 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_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..717e0697 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -5,65 +5,137 @@ 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 and Game Center 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(); + + @override + TwitterSignInArgs get desktopSignInArgs { + final apiKey = this.apiKey; + final apiSecretKey = this.apiSecretKey; + + if (apiKey == null || apiSecretKey == null) { + 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 == null && apiSecretKey == null) 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 ' + '"${redirectUri ?? 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', + ); + } @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) { + throw UnsupportedError( + '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.', + ); + } + + _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(authListener.onError); + return; + } + + auth + .signInWithProvider(firebaseAuthProvider) + .then(authListener.onSignedIn) + .catchError(authListener.onError); } @override @@ -74,9 +146,6 @@ class TwitterProvider extends OAuthProvider { ); } - @override - TwitterAuthProvider get firebaseAuthProvider => TwitterAuthProvider(); - @override Future logOutProvider() { return SynchronousFuture(null); @@ -86,4 +155,8 @@ class TwitterProvider extends OAuthProvider { bool supportsPlatform(TargetPlatform platform) { return true; } + + void _onLinked(UserCredential userCredential) { + authListener.onCredentialLinked(userCredential.credential!); + } } diff --git a/packages/firebase_ui_oauth_twitter/pubspec.yaml b/packages/firebase_ui_oauth_twitter/pubspec.yaml index 2b5d54e2..f413d57d 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.0 - 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/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..7350a134 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,159 @@ 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; + + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); + + 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)); + }); + + test('throws when AuthAction.none is used', () { + provider.auth = auth; + + expect( + () => provider.mobileSignIn(AuthAction.none), + throwsUnsupportedError, + ); + }); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); + + group('TwitterProvider', () { + test('throws from desktopSignInArgs when the API keys are missing', () { + expect(() => TwitterProvider().desktopSignInArgs, throwsArgumentError); + }); + }); } -// 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 MockListener extends Mock { + void call(AuthState? state) { + super.noSuchMethod(Invocation.method(#call, [state])); + } +} + +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 + 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; + + @override + fba.User? get currentUser => currentUserOverride; + @override - Future login({bool? forceLogin}) async { + Future signInWithProvider(Object provider) async { 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 93d93347..ea9adf98 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: firebase_ui_oauth_google: ^2.1.0 firebase_ui_oauth: ^2.1.0 flutter_facebook_auth: ^7.1.2 - twitter_login: ^4.4.2 firebase_ui_oauth_twitter: ^2.1.0 cloud_firestore: 6.9.0 firebase_ui_firestore: ^2.1.0