diff --git a/app_flutter/README.md b/app_flutter/README.md index 788a281822..4495265a6f 100644 --- a/app_flutter/README.md +++ b/app_flutter/README.md @@ -1,3 +1,9 @@ # Build Dashboard v2 Frontend -[Design Doc](https://flutter.dev/go/build-dashboard-v2) \ No newline at end of file +[Design Doc](https://flutter.dev/go/build-dashboard-v2) + +## Running for web locally + +`flutter run -d chrome --web-port=8080` + +Must run on port 8080 for Google Sign In to work since that is the only enabled port for localhost. diff --git a/app_flutter/lib/build_dashboard.dart b/app_flutter/lib/build_dashboard.dart index 0b038a325e..fc3d20457b 100644 --- a/app_flutter/lib/build_dashboard.dart +++ b/app_flutter/lib/build_dashboard.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:app_flutter/service/google_authentication.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -52,6 +53,9 @@ class BuildDashboard extends StatelessWidget { backgroundColor: buildState.isTreeBuilding.data ? theme.primaryColor : theme.errorColor, + actions: [ + UserAvatar(buildState: buildState), + ], ), body: Column( children: const [ @@ -62,3 +66,27 @@ class BuildDashboard extends StatelessWidget { ); } } + +/// Widget for displaying sign in information for the current user. +/// +/// If logged in, it will display the user's avatar. Otherwise, it will show +/// a button for sign in. +class UserAvatar extends StatelessWidget { + const UserAvatar({@required this.buildState, Key key}) : super(key: key); + + final FlutterBuildState buildState; + + @override + Widget build(BuildContext context) { + final GoogleSignInService authService = buildState.authService; + + if (authService.isAuthenticated) { + return Image.network(authService.avatarUrl); + } + + return FlatButton( + child: const Text('Sign in'), + onPressed: () => buildState.signIn(), + ); + } +} diff --git a/app_flutter/lib/service/google_authentication.dart b/app_flutter/lib/service/google_authentication.dart new file mode 100644 index 0000000000..dc42bd9b14 --- /dev/null +++ b/app_flutter/lib/service/google_authentication.dart @@ -0,0 +1,53 @@ +// Copyright 2019 The Chromium Authors. 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:google_sign_in_all/google_sign_in_all.dart'; + +/// Service class for interacting with Google Sign In authentication for Cocoon backend. +class GoogleSignInService { + /// Creates a new [GoogleSignIn]. + GoogleSignInService({GoogleSignIn googleSignIn}) + : _googleSignIn = googleSignIn ?? + setupGoogleSignIn( + scopes: _googleScopes, + webClientId: + '308150028417-vlj9mqlm3gk1d03fb0efif1fu5nagdtt.apps.googleusercontent.com', + ); + + /// A list of Google API OAuth Scopes this project needs access to. + /// + /// Currently, the project shows just basic user profile information + /// when logged in. + /// + /// See https://developers.google.com/identity/protocols/googlescopes + static const List _googleScopes = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]; + + // TODO(chillers): Switch to official Flutter plugin when it supports web. + final GoogleSignIn _googleSignIn; + + AuthCredentials _credentials; + + GoogleAccount _user; + + /// Whether or not the application has been signed in to. + bool get isAuthenticated => _credentials?.accessToken != null; + + /// The profile photo url of the current user signed in. + String get avatarUrl => _user?.photoUrl; + + /// The email of the current user signed in. + String get email => _user?.email; + + /// Authentication token to be sent to Cocoon Backend to verify API calls. + String get accessToken => _credentials?.accessToken; + + /// Initiate the Google Sign In process. + Future signIn() async { + _credentials = await _googleSignIn.signIn(); + _user = await _googleSignIn.getCurrentUser(); + } +} diff --git a/app_flutter/lib/state/flutter_build.dart b/app_flutter/lib/state/flutter_build.dart index 7d29843a23..f9dbc4cd22 100644 --- a/app_flutter/lib/state/flutter_build.dart +++ b/app_flutter/lib/state/flutter_build.dart @@ -9,18 +9,25 @@ import 'package:flutter/foundation.dart'; import 'package:cocoon_service/protos.dart' show CommitStatus; import '../service/cocoon.dart'; +import '../service/google_authentication.dart'; /// State for the Flutter Build Dashboard class FlutterBuildState extends ChangeNotifier { /// Creates a new [FlutterBuildState]. /// /// If [CocoonService] is not specified, a new [CocoonService] instance is created. - FlutterBuildState({CocoonService cocoonService}) - : _cocoonService = cocoonService ?? CocoonService(); + FlutterBuildState({ + CocoonService cocoonService, + GoogleSignInService authService, + }) : authService = authService ?? GoogleSignInService(), + _cocoonService = cocoonService ?? CocoonService(); /// Cocoon backend service that retrieves the data needed for this state. final CocoonService _cocoonService; + /// Authentication service for managing Google Sign In. + final GoogleSignInService authService; + /// How often to query the Cocoon backend for the current build state. @visibleForTesting final Duration refreshRate = const Duration(seconds: 10); @@ -69,6 +76,11 @@ class FlutterBuildState extends ChangeNotifier { notifyListeners(); } + Future signIn() async { + await authService.signIn(); + notifyListeners(); + } + @override void dispose() { refreshTimer?.cancel(); diff --git a/app_flutter/pubspec.yaml b/app_flutter/pubspec.yaml index 103ad0b411..1593d4309d 100644 --- a/app_flutter/pubspec.yaml +++ b/app_flutter/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: cocoon_service: path: ../app_dart + google_sign_in_all: ^0.0.3 provider: ^3.0.0 url_launcher: ^5.1.0 url_launcher_web: diff --git a/app_flutter/test/build_dashboard_test.dart b/app_flutter/test/build_dashboard_test.dart new file mode 100644 index 0000000000..6e3ae9c72b --- /dev/null +++ b/app_flutter/test/build_dashboard_test.dart @@ -0,0 +1,75 @@ +// Copyright (c) 2019 The Chromium Authors. 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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart' as test; + +import 'package:app_flutter/build_dashboard.dart'; +import 'package:app_flutter/service/google_authentication.dart'; +import 'package:app_flutter/service/fake_cocoon.dart'; +import 'package:app_flutter/state/flutter_build.dart'; + +void main() { + group('UserAvatar', () { + GoogleSignInService authService; + + setUp(() { + authService = MockGoogleSignInService(); + }); + + testWidgets('shows sign in button when not signed in', + (WidgetTester tester) async { + when(authService.isAuthenticated).thenReturn(false); + final FlutterBuildState buildState = FlutterBuildState( + authService: authService, cocoonService: FakeCocoonService()); + await tester.pumpWidget(MaterialApp( + home: UserAvatar( + buildState: buildState, + ), + )); + + expect(find.text('Sign in'), findsOneWidget); + }); + + testWidgets('sign in button activates google sign in when pressed', + (WidgetTester tester) async { + when(authService.isAuthenticated).thenReturn(false); + final FlutterBuildState buildState = FlutterBuildState( + authService: authService, cocoonService: FakeCocoonService()); + await tester.pumpWidget(MaterialApp( + home: UserAvatar( + buildState: buildState, + ), + )); + + verifyNever(authService.signIn()); + + await tester.tap(find.byType(UserAvatar)); + + verify(authService.signIn()).called(1); + }); + + testWidgets('shows user avatar when signed in', + (WidgetTester tester) async { + when(authService.isAuthenticated).thenReturn(true); + when(authService.avatarUrl).thenReturn('https://flutter.dev'); + final FlutterBuildState buildState = FlutterBuildState( + authService: authService, cocoonService: FakeCocoonService()); + await tester.pumpWidget(MaterialApp( + home: UserAvatar( + buildState: buildState, + ), + )); + + expect(tester.takeException(), + const test.TypeMatcher()); + expect(find.byType(Image), findsOneWidget); + }); + }); +} + +/// Mock [GoogleSignInService] for testing interactions. +class MockGoogleSignInService extends Mock implements GoogleSignInService {} diff --git a/app_flutter/test/service/google_authentication_test.dart b/app_flutter/test/service/google_authentication_test.dart new file mode 100644 index 0000000000..529b500508 --- /dev/null +++ b/app_flutter/test/service/google_authentication_test.dart @@ -0,0 +1,86 @@ +// Copyright 2019 The Chromium Authors. 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:google_sign_in_all/google_sign_in_all.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'package:app_flutter/service/google_authentication.dart'; + +void main() { + group('GoogleSignInService not signed in', () { + GoogleSignInService authService; + + setUp(() { + authService = GoogleSignInService(googleSignIn: MockGoogleSignIn()); + }); + + test('not authenticated', () { + expect(authService.isAuthenticated, false); + }); + + test('no user information', () { + expect(authService.avatarUrl, null); + expect(authService.email, null); + expect(authService.accessToken, null); + }); + }); + + group('GoogleSignInService sign in', () { + GoogleSignInService authService; + GoogleSignIn mockSignIn; + + setUp(() { + mockSignIn = MockGoogleSignIn(); + final AuthCredentials fakeCredentials = FakeAuthCredentials(); + when(mockSignIn.signIn()) + .thenAnswer((_) => Future.value(fakeCredentials)); + when(mockSignIn.getCurrentUser()).thenAnswer((_) => + Future.value(GoogleAccount( + email: 'fake@fake.com', photoUrl: 'fake://fake.png'))); + + authService = GoogleSignInService(googleSignIn: mockSignIn); + }); + + test('is authenticated after successful sign in', () async { + await authService.signIn(); + + expect(authService.isAuthenticated, true); + }); + + test('there is user information after successful sign in', () async { + await authService.signIn(); + + expect(authService.email, 'fake@fake.com'); + expect(authService.avatarUrl, 'fake://fake.png'); + expect(authService.accessToken, 'fake'); + }); + + test('is not authenticated after failure in sign in', () async { + when(mockSignIn.signIn()) + .thenAnswer((_) => Future.value(null)); + when(mockSignIn.getCurrentUser()) + .thenAnswer((_) => Future.value(null)); + + await authService.signIn(); + + expect(authService.isAuthenticated, false); + expect(authService.email, null); + expect(authService.avatarUrl, null); + expect(authService.accessToken, null); + }); + }); +} + +/// Mock [GoogleSignIn] for testing interactions. +class MockGoogleSignIn extends Mock implements GoogleSignIn {} + +/// Fake [AuthCredentials] for [MockGoogleSignIn]. +class FakeAuthCredentials implements AuthCredentials { + @override + final String accessToken = 'fake'; + + @override + final String idToken = 'faker'; +}