From 007e3799fda571cd5836101cb29bc7a31eab002e Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:15:56 +0100 Subject: [PATCH 1/3] feat(ui_oauth_twitter)!: replace twitter_login with signInWithProvider twitter_login has not published since July 2023 and ships an Android build.gradle with no namespace that pins AGP 4.1.0, which capped this repo at AGP 8.7.3 and blocked #693 from using flutter_web_auth_2. TwitterProvider now signs in through auth.signInWithProvider on Android and iOS, mirroring AppleProvider, so Firebase performs the OAuth dance and the Twitter API key and secret move out of the app binary into the Firebase console. macOS and Windows keep the vendored OAuth 1.0a flow, which is why apiKey and apiSecretKey survive as optional parameters rather than being removed. macOS stays on the desktop flow because signInWithProvider is not available to it: FLTFirebaseAuthPlugin.swift carves out Apple and Game Center, then fails every other provider under `#if os(macOS)` with unsupported-platform. Android has no equivalent restriction. - AuthAction.none throws UnsupportedError on Android and iOS, since signInWithProvider cannot return a credential without also creating a session. - Anonymous users are upgraded with linkWithProvider so the anonymous uid survives sign in. - A debug-only diagnostic warns once when apiKey or apiSecretKey are passed on a platform that now ignores them. - Restores compileSdk to flutter.compileSdkVersion and bumps AGP to 8.9.1, now that nothing pins it. BREAKING CHANGE: consumers must set the Twitter app callback URL to the Firebase auth handler, add the Encoded App ID URL scheme on iOS, and register their SHA-1 on Android. AuthAction.none now throws on Android and iOS, and the credential passed to onCredentialLinked is a plain AuthCredential rather than an OAuthCredential. --- .github/workflows/e2e.yml | 3 - .../firebase_ui_auth/example/pubspec.yaml | 1 - .../lib/firebase_ui_oauth_twitter.dart | 16 +- .../lib/src/provider.dart | 149 +++++++++++----- .../firebase_ui_oauth_twitter/pubspec.yaml | 1 - scripts/patch-twitter-login.sh | 51 ------ tests/android/app/build.gradle | 3 +- tests/android/settings.gradle | 2 +- .../twitter_sign_in_test.dart | 167 +++++++++++++----- tests/pubspec.yaml | 1 - 10 files changed, 238 insertions(+), 156 deletions(-) delete mode 100755 scripts/patch-twitter-login.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3887ce65..309dbb03 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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..88606b29 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -5,65 +5,119 @@ 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) { + auth.currentUser + ?.linkWithProvider(firebaseAuthProvider) + .then(_onLinked) + .catchError(authListener.onError); + return; + } + + auth + .signInWithProvider(firebaseAuthProvider) + .then(authListener.onSignedIn) + .catchError(authListener.onError); } @override @@ -74,9 +128,6 @@ class TwitterProvider extends OAuthProvider { ); } - @override - TwitterAuthProvider get firebaseAuthProvider => TwitterAuthProvider(); - @override Future logOutProvider() { return SynchronousFuture(null); @@ -86,4 +137,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..eb9cd95a 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,130 @@ 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)); + }); + + 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); + }); + }); +} + +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 + String? get displayName => 'Test User'; + + @override + String? get email => 'test@test.com'; + @override - TwitterLoginStatus? get status => TwitterLoginStatus.loggedIn; + bool get isAnonymous => false; +} + +class MockAnonymousUser extends Mock implements fba.User { @override - String? get authToken => _jwt; + bool get isAnonymous => true; + @override - String? get authTokenSecret => 'secret'; + Future linkWithProvider(Object provider) async { + return super.noSuchMethod( + Invocation.method(#linkWithProvider, [provider]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } } -class MockTwitterLogin extends Mock implements TwitterLogin { +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 From 7f199c4cc5227c493190bbf39d874804ad4bd68c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:01:40 +0100 Subject: [PATCH 2/3] fix(ui_oauth_twitter): report an error when linking without a signed in user AuthAction.link with no FirebaseAuth.currentUser null-shorted the linkWithProvider call, so the flow neither completed nor reported an error and the UI stayed in its loading state. The credential path this replaced raised through auth.currentUser!, so a null user was at least surfaced. Reports a FirebaseAuthException instead, which reaches AuthFailed rather than escaping as an unhandled Error the way a StateError would. --- .../lib/src/provider.dart | 22 ++++++++++++-- .../twitter_sign_in_test.dart | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 88606b29..717e0697 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -107,8 +107,26 @@ class TwitterProvider extends OAuthProvider { // Linking is also used to upgrade an anonymous user, so that the // anonymous uid survives the sign in. if (action == AuthAction.link || shouldUpgradeAnonymous) { - auth.currentUser - ?.linkWithProvider(firebaseAuthProvider) + 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; 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 eb9cd95a..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 @@ -113,6 +113,35 @@ void main() async { 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; From e28e2f73aebf4a16103e0bd7d60ebefe128669de Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:06:26 +0100 Subject: [PATCH 3/3] ci(e2e): correct stale version comment on the setup-gradle pin The pin comment claimed v6 while the pinned SHA is v6.2.0, and upstream has since moved the v6 tag to v6.3.0. zizmor flagged the mismatch as a medium severity finding, which blocks the workflow check. Corrects the comment rather than moving the pin, so the action version CI runs is unchanged. --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 309dbb03..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