diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 45c557f..39f8f07 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,5 +1,8 @@ plugins { id "com.android.application" + // START: FlutterFire Configuration + id 'com.google.gms.google-services' + // END: FlutterFire Configuration id "kotlin-android" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" @@ -8,7 +11,7 @@ plugins { android { namespace = "com.example.phone_auth_handler_demo" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = flutter.ndkVersion // ndkVersion = "29.0.14206865" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -24,7 +27,7 @@ android { applicationId = "com.example.phone_auth_handler_demo" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 + minSdkVersion = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/example/android/build.gradle b/example/android/build.gradle index 4365010..d2ffbff 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,11 +1,3 @@ -buildscript { - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath 'com.google.gms:google-services:4.3.10' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6..9470727 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/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.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip \ No newline at end of file diff --git a/example/android/settings.gradle b/example/android/settings.gradle index b9e43bd..7236bcd 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,11 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.11.0" apply false + // START: FlutterFire Configuration + id "com.google.gms.google-services" version "4.3.15" apply false + // END: FlutterFire Configuration + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app" diff --git a/example/lib/utils/app_theme.dart b/example/lib/utils/app_theme.dart index 4a7f6e6..202e2f2 100644 --- a/example/lib/utils/app_theme.dart +++ b/example/lib/utils/app_theme.dart @@ -56,7 +56,7 @@ class AppTheme { // selectionColor: accentColor?.withOpacity(0.75), // selectionHandleColor: accentColor?.withOpacity(0.75), // ), - dialogTheme: DialogTheme( + dialogTheme: DialogThemeData( elevation: _defaultElevation, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1fd2109..d436d31 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -12,15 +12,15 @@ dependencies: flutter: sdk: flutter - pinput: ^5.0.0 - firebase_core: ^3.6.0 - easy_container: ^1.0.5 + pinput: ^6.0.2 + firebase_core: ^4.7.0 + easy_container: ^1.0.5+1 intl_phone_field: ^3.2.0 firebase_phone_auth_handler: path: "../" dev_dependencies: - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: diff --git a/lib/src/auth_controller.dart b/lib/src/auth_controller.dart index 077199d..ebb8095 100644 --- a/lib/src/auth_controller.dart +++ b/lib/src/auth_controller.dart @@ -1,18 +1,21 @@ part of 'auth_handler.dart'; class FirebasePhoneAuthController extends ChangeNotifier { + /// Internal constructor for injection. + FirebasePhoneAuthController({FirebaseAuth? auth}) + : _auth = auth ?? FirebaseAuth.instance; + + final FirebaseAuth _auth; + static FirebasePhoneAuthController _of( - BuildContext context, { - bool listen = true, - }) => + BuildContext context, { + bool listen = true, + }) => Provider.of(context, listen: listen); /// {@macro autoRetrievalTimeOutDuration} static const kAutoRetrievalTimeOutDuration = Duration(minutes: 1); - /// Firebase auth instance using the default [FirebaseApp]. - static final FirebaseAuth _auth = FirebaseAuth.instance; - /// Web confirmation result for OTP. ConfirmationResult? _webConfirmationResult; @@ -93,7 +96,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { /// button, to let user request a new OTP. Duration get otpExpirationTimeLeft { final otpTickDuration = Duration( - seconds: (_otpExpirationTimer?.tick ?? 0), + seconds: _otpExpirationTimer?.tick ?? 0, ); return _otpExpirationDuration - otpTickDuration; } @@ -106,7 +109,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { /// the OTP, and requires user to manually enter it. Duration get autoRetrievalTimeLeft { final otpTickDuration = Duration( - seconds: (_otpAutoRetrievalTimer?.tick ?? 0), + seconds: _otpAutoRetrievalTimer?.tick ?? 0, ); return _autoRetrievalTimeOutDuration - otpTickDuration; } @@ -143,7 +146,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { try { if (kIsWeb) { final userCredential = await _webConfirmationResult!.confirm(otp); - return await _loginUser( + return _loginUser( userCredential: userCredential, autoVerified: false, ); @@ -152,7 +155,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { verificationId: _verificationId!, smsCode: otp, ); - return await _loginUser( + return _loginUser( authCredential: credential, autoVerified: false, ); @@ -181,16 +184,18 @@ class FirebasePhoneAuthController extends ChangeNotifier { /// code send callback to be fired, and [sendOTP] will complete only after /// that callback is fired. Not applicable on web. Future sendOTP({bool shouldAwaitCodeSend = true}) async { + if (_phoneNumber == null) return false; + Completer? codeSendCompleter; codeSent = false; await Future.delayed(Duration.zero, notifyListeners); - verificationCompletedCallback(AuthCredential authCredential) async { + void verificationCompletedCallback(AuthCredential authCredential) async { await _loginUser(authCredential: authCredential, autoVerified: true); } - verificationFailedCallback(FirebaseAuthException authException) { + void verificationFailedCallback(FirebaseAuthException authException) { final stackTrace = authException.stackTrace ?? StackTrace.current; if (codeSendCompleter != null && !codeSendCompleter.isCompleted) { @@ -199,10 +204,10 @@ class FirebasePhoneAuthController extends ChangeNotifier { _onLoginFailed?.call(authException, stackTrace); } - codeSentCallback( - String verificationId, [ - int? forceResendingToken, - ]) async { + Future codeSentCallback( + String verificationId, [ + int? forceResendingToken, + ]) async { _verificationId = verificationId; _forceResendingToken = forceResendingToken; codeSent = true; @@ -213,7 +218,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { _setTimer(); } - codeAutoRetrievalTimeoutCallback(String verificationId) { + void codeAutoRetrievalTimeoutCallback(String verificationId) { _verificationId = verificationId; } @@ -309,7 +314,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { void _setTimer() { _otpExpirationTimer = Timer.periodic( const Duration(seconds: 1), - (timer) { + (timer) { if (timer.tick == _otpExpirationDuration.inSeconds) { _otpExpirationTimer?.cancel(); } @@ -320,7 +325,7 @@ class FirebasePhoneAuthController extends ChangeNotifier { ); _otpAutoRetrievalTimer = Timer.periodic( const Duration(seconds: 1), - (timer) { + (timer) { if (timer.tick == _autoRetrievalTimeOutDuration.inSeconds) { _otpAutoRetrievalTimer?.cancel(); } diff --git a/lib/src/auth_handler.dart b/lib/src/auth_handler.dart index 74ea0e7..6862f33 100644 --- a/lib/src/auth_handler.dart +++ b/lib/src/auth_handler.dart @@ -182,12 +182,12 @@ class FirebasePhoneAuthHandler extends StatefulWidget { class _FirebasePhoneAuthHandlerState extends State { @override void initState() { - (() async { + unawaited(() async { final con = FirebasePhoneAuthController._of(context, listen: false); RecaptchaVerifier? captcha; - if (widget.recaptchaVerifierForWebProvider != null) { - captcha = widget.recaptchaVerifierForWebProvider!(kIsWeb); + if (kIsWeb && widget.recaptchaVerifierForWebProvider != null) { + captcha = widget.recaptchaVerifierForWebProvider!(true); } con._setData( @@ -204,7 +204,7 @@ class _FirebasePhoneAuthHandlerState extends State { ); if (widget.sendOtpOnInitialize) await con.sendOTP(); - })(); + }()); super.initState(); } diff --git a/lib/src/auth_provider.dart b/lib/src/auth_provider.dart index d7fd0a4..ddca721 100644 --- a/lib/src/auth_provider.dart +++ b/lib/src/auth_provider.dart @@ -1,3 +1,4 @@ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_phone_auth_handler/src/auth_handler.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,15 +9,19 @@ class FirebasePhoneAuthProvider extends StatelessWidget { const FirebasePhoneAuthProvider({ super.key, required this.child, + this.auth, }); /// The child of the widget. final Widget child; + /// The [FirebaseAuth] instance to use. + final FirebaseAuth? auth; + @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (_) => FirebasePhoneAuthController(), + create: (_) => FirebasePhoneAuthController(auth: auth), child: child, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 9d7988e..ea0f689 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,11 +26,15 @@ dependencies: flutter: sdk: flutter - provider: ^6.1.2 - firebase_auth: ^5.3.1 + provider: ^6.1.5+1 + firebase_auth: ^6.4.0 dev_dependencies: - flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + mocktail: ^1.0.5 + firebase_auth_mocks: ^0.15.1 false_secrets: - /example/android/app/google-services.json diff --git a/test/firebase_phone_auth_controller_test.dart b/test/firebase_phone_auth_controller_test.dart new file mode 100644 index 0000000..c5ef374 --- /dev/null +++ b/test/firebase_phone_auth_controller_test.dart @@ -0,0 +1,55 @@ + +import 'package:firebase_phone_auth_handler/firebase_phone_auth_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'mocks.dart'; + +void main() { + late MockFirebaseAuth auth; + late FirebasePhoneAuthController controller; + + setUpAll(() { + registerFallbackValue(MockAuthCredential()); + registerFallbackValue(MockFirebaseAuthException()); + registerFallbackValue(const Duration(seconds: 30)); + }); + + setUp(() { + auth = MockFirebaseAuth(); + when(() => auth.signOut()).thenAnswer((_) async => {}); + controller = FirebasePhoneAuthController(auth: auth); + }); + + group('FirebasePhoneAuthController - Verification Logic', () { + test('sendOTP fails if phoneNumber is not set', () async { + // Expect error or false because _phoneNumber is null initially + final result = await controller.sendOTP(); + expect(result, isFalse); + }); + + test('verifyOtp returns false if verificationId is missing', () async { + final result = await controller.verifyOtp('123456'); + expect(result, isFalse); + }); + }); + + group('FirebasePhoneAuthController - Basic State', () { + test('initial state is correct', () { + expect(controller.codeSent, isFalse); + expect(controller.isSendingCode, isTrue); + expect(controller.isOtpExpired, isTrue); + }); + + test('clear() resets state', () { + controller.clear(); + expect(controller.codeSent, isFalse); + expect(controller.isOtpExpired, isTrue); + }); + + test('signOut() calls firebase auth signOut', () async { + await controller.signOut(); + verify(() => auth.signOut()).called(1); + }); + }); +} diff --git a/test/firebase_phone_auth_handler_test.dart b/test/firebase_phone_auth_handler_test.dart new file mode 100644 index 0000000..b1b4e6a --- /dev/null +++ b/test/firebase_phone_auth_handler_test.dart @@ -0,0 +1,183 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_phone_auth_handler/firebase_phone_auth_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'mocks.dart'; + +void main() { + late MockFirebaseAuth auth; + + setUpAll(() { + registerFallbackValue(MockAuthCredential()); + registerFallbackValue(MockFirebaseAuthException()); + registerFallbackValue(const Duration(seconds: 30)); + }); + + setUp(() { + auth = MockFirebaseAuth(); + when(() => auth.signOut()).thenAnswer((_) async => {}); + }); + + testWidgets('FirebasePhoneAuthHandler triggers onCodeSent when OTP is sent', + (WidgetTester tester) async { + bool codeSentCalled = false; + + // Mock verifyPhoneNumber to trigger codeSent callback + when(() => auth.verifyPhoneNumber( + phoneNumber: any(named: 'phoneNumber'), + verificationCompleted: any(named: 'verificationCompleted'), + verificationFailed: any(named: 'verificationFailed'), + codeSent: any(named: 'codeSent'), + codeAutoRetrievalTimeout: any(named: 'codeAutoRetrievalTimeout'), + timeout: any(named: 'timeout'), + forceResendingToken: any(named: 'forceResendingToken'), + )).thenAnswer((invocation) async { + final codeSentCallback = + invocation.namedArguments[#codeSent] as PhoneCodeSent; + codeSentCallback('test-ver-id', 123); + }); + + await tester.pumpWidget( + FirebasePhoneAuthProvider( + auth: auth, + child: MaterialApp( + home: Scaffold( + body: FirebasePhoneAuthHandler( + phoneNumber: '+1234567890', + sendOtpOnInitialize: true, + onCodeSent: () { + codeSentCalled = true; + }, + builder: (context, controller) { + return Text(controller.codeSent ? 'CODE_SENT' : 'SENDING'); + }, + ), + ), + ), + ), + ); + + // Initial pump might not trigger the async callback immediately + await tester.pumpAndSettle(); + + expect(codeSentCalled, isTrue); + expect(find.text('CODE_SENT'), findsOneWidget); + }); + + testWidgets( + 'FirebasePhoneAuthHandler triggers onLoginSuccess on verification success', + (WidgetTester tester) async { + UserCredential? successCredential; + final mockUserCredential = MockUserCredential(); + final mockUser = MockUser(); + when(() => mockUserCredential.user).thenReturn(mockUser); + + // Mock verifyPhoneNumber to trigger codeSent + when(() => auth.verifyPhoneNumber( + phoneNumber: any(named: 'phoneNumber'), + verificationCompleted: any(named: 'verificationCompleted'), + verificationFailed: any(named: 'verificationFailed'), + codeSent: any(named: 'codeSent'), + codeAutoRetrievalTimeout: any(named: 'codeAutoRetrievalTimeout'), + timeout: any(named: 'timeout'), + forceResendingToken: any(named: 'forceResendingToken'), + )).thenAnswer((invocation) async { + final codeSentCallback = + invocation.namedArguments[#codeSent] as PhoneCodeSent; + codeSentCallback('test-ver-id', 123); + }); + + // Mock signInWithCredential for manual verification + when(() => auth.signInWithCredential(any())) + .thenAnswer((_) async => mockUserCredential); + + await tester.pumpWidget( + FirebasePhoneAuthProvider( + auth: auth, + child: MaterialApp( + home: Scaffold( + body: FirebasePhoneAuthHandler( + phoneNumber: '+1234567890', + sendOtpOnInitialize: true, + onLoginSuccess: (credential, autoVerified) { + successCredential = credential; + }, + builder: (context, controller) { + return ElevatedButton( + onPressed: () => controller.verifyOtp('123456'), + child: const Text('VERIFY'), + ); + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); // Wait for codeSent + await tester.tap(find.text('VERIFY')); + await tester.pumpAndSettle(); + + expect(successCredential, isNotNull); + verify(() => auth.signInWithCredential(any())).called(1); + }); + + testWidgets( + 'FirebasePhoneAuthHandler triggers onLoginFailed on verification failure', + (WidgetTester tester) async { + FirebaseAuthException? loginException; + + // Mock verifyPhoneNumber to trigger codeSent + when(() => auth.verifyPhoneNumber( + phoneNumber: any(named: 'phoneNumber'), + verificationCompleted: any(named: 'verificationCompleted'), + verificationFailed: any(named: 'verificationFailed'), + codeSent: any(named: 'codeSent'), + codeAutoRetrievalTimeout: any(named: 'codeAutoRetrievalTimeout'), + timeout: any(named: 'timeout'), + forceResendingToken: any(named: 'forceResendingToken'), + )).thenAnswer((invocation) async { + final codeSentCallback = + invocation.namedArguments[#codeSent] as PhoneCodeSent; + codeSentCallback('test-ver-id', 123); + }); + + // Mock signInWithCredential to throw FirebaseAuthException + final mockException = MockFirebaseAuthException(); + when(() => mockException.code).thenReturn('invalid-verification-code'); + when(() => mockException.message).thenReturn('The code is invalid.'); + when(() => auth.signInWithCredential(any())).thenThrow(mockException); + + await tester.pumpWidget( + FirebasePhoneAuthProvider( + auth: auth, + child: MaterialApp( + home: Scaffold( + body: FirebasePhoneAuthHandler( + phoneNumber: '+1234567890', + sendOtpOnInitialize: true, + onLoginFailed: (exception, stackTrace) { + loginException = exception; + }, + builder: (context, controller) { + return ElevatedButton( + onPressed: () => controller.verifyOtp('000000'), + child: const Text('VERIFY_FAIL'), + ); + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('VERIFY_FAIL')); + await tester.pumpAndSettle(); + + expect(loginException, isNotNull); + expect(loginException!.code, 'invalid-verification-code'); + }); +} diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 0000000..232cc03 --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,16 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFirebaseAuth extends Mock implements FirebaseAuth {} + +class MockConfirmationResult extends Mock implements ConfirmationResult {} + +class MockAuthCredential extends Mock implements AuthCredential {} + +class MockPhoneAuthCredential extends Mock implements PhoneAuthCredential {} + +class MockUserCredential extends Mock implements UserCredential {} + +class MockUser extends Mock implements User {} + +class MockFirebaseAuthException extends Mock implements FirebaseAuthException {}