From e785bb042885deb28c50ab4370adf6c81be1b464 Mon Sep 17 00:00:00 2001 From: Grisha Kruglov Date: Wed, 23 Jan 2019 14:27:31 -0800 Subject: [PATCH] Add a lightweight FirefoxAccountsAuthFeature Ties together an account manager with a tabs use cases. Let's see how it pans out in the reference browser! --- .buildconfig.yml | 4 + README.md | 2 + components/feature/accounts/README.md | 18 +++ components/feature/accounts/build.gradle | 39 +++++ .../feature/accounts/proguard-rules.pro | 21 +++ .../accounts/src/main/AndroidManifest.xml | 5 + .../accounts/FirefoxAccountsAuthFeature.kt | 65 ++++++++ .../FirefoxAccountsAuthFeatureTest.kt | 150 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 2 + components/feature/tabs/build.gradle | 2 +- 10 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 components/feature/accounts/README.md create mode 100644 components/feature/accounts/build.gradle create mode 100644 components/feature/accounts/proguard-rules.pro create mode 100644 components/feature/accounts/src/main/AndroidManifest.xml create mode 100644 components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt create mode 100644 components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt create mode 100644 components/feature/accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/.buildconfig.yml b/.buildconfig.yml index 973f88d8470..a265b175d60 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -28,6 +28,10 @@ projects: path: components/feature/awesomebar description: 'Component connecting a concept-toolbar with a concept-awesomebar.' publish: true + feature-accounts: + path: components/feature/accounts + description: 'Component for tying an account manager with the tabs feature to facilitate auth flows.' + publish: true feature-contextmenu: path: components/feature/contextmenu description: 'Component for displaying context menus for web content.' diff --git a/README.md b/README.md index f712e45e584..be0354f38e0 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ _API contracts and abstraction layers for browser components._ _Combined components to implement feature-specific use cases._ +* 🔴 [**Accounts**](components/feature/accounts/README.md) - A component that connects an FxaAccountManager from [service-firefox-accounts](components/service/firefox-accounts/README.md) with [feature-tabs](components/feature/tabs/README.md) in order to facilitate authentication flows. + * ⚪ [**Awesomebar**](components/feature/awesomebar/README.md) - A component that connects a [concept-awesomebar](components/concept/awesomebar/README.md) implementation to a [concept-toolbar](components/concept/toolbar/README.md) implementation and provides implementations of various suggestion providers. * ⚪ [**Context Menu**](components/feature/contextmenu/README.md) - A component for displaying context menus when *long-pressing* web content. diff --git a/components/feature/accounts/README.md b/components/feature/accounts/README.md new file mode 100644 index 00000000000..6f8c7889a43 --- /dev/null +++ b/components/feature/accounts/README.md @@ -0,0 +1,18 @@ +# [Android Components](../../../README.md) > Feature > Accounts + +A component which ties together an FxaAccountManager with the tabs feature, to +facilitate OAuth authentication flows managed by the account manager. + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:feature-accounts:{latest-version}" +``` + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/components/feature/accounts/build.gradle b/components/feature/accounts/build.gradle new file mode 100644 index 00000000000..0c14931b884 --- /dev/null +++ b/components/feature/accounts/build.gradle @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation Dependencies.kotlin_coroutines + + implementation project(':concept-engine') + implementation project(':feature-tabs') + implementation project(':service-firefox-accounts') + + testImplementation Dependencies.testing_junit + testImplementation Dependencies.testing_mockito + testImplementation Dependencies.testing_robolectric + + testImplementation project(':support-test') +} + +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/feature/accounts/proguard-rules.pro b/components/feature/accounts/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/components/feature/accounts/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/components/feature/accounts/src/main/AndroidManifest.xml b/components/feature/accounts/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..60f47e2a0de --- /dev/null +++ b/components/feature/accounts/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt new file mode 100644 index 00000000000..6e80bf75d2a --- /dev/null +++ b/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.accounts + +import android.net.Uri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.request.RequestInterceptor +import mozilla.components.feature.tabs.TabsUseCases +import mozilla.components.service.fxa.FxaAccountManager +import mozilla.components.service.fxa.FxaException +import kotlin.coroutines.CoroutineContext + +/** + * Ties together an account manager with a session manager/tabs implementation, facilitating an + * authentication flow. + */ +class FirefoxAccountsAuthFeature( + private val accountManager: FxaAccountManager, + private val tabsUseCases: TabsUseCases, + private val redirectUrl: String, + private val successPath: String, + private val coroutineContext: CoroutineContext = Dispatchers.Main +) { + fun beginAuthentication() { + CoroutineScope(coroutineContext).launch { + val authUrl = try { + accountManager.beginAuthentication().await() + } catch (e: FxaException) { + // FIXME return a fallback URL provided by Config... + "https://accounts.firefox.com/signin" + } + // TODO + // We may fail to obtain an authentication URL, for example due to transient network errors. + // If that happens, open up a fallback URL in order to present some kind of a "no network" + // UI to the user. + // It's possible that the underlying problem will go away by the time the tab actually + // loads, resulting in a confusing experience. + tabsUseCases.addTab.invoke(authUrl) + } + } + + val interceptor = object : RequestInterceptor { + override fun onLoadRequest(session: EngineSession, uri: String): RequestInterceptor.InterceptionResponse? { + if (!uri.startsWith(redirectUrl)) { + return null + } + + val parsedUri = Uri.parse(uri) + val code = parsedUri.getQueryParameter("code") as String + val state = parsedUri.getQueryParameter("state") as String + + // Notify the state machine about our success. + accountManager.finishAuthentication(code, state) + + // TODO this can be simplified once https://github.com/mozilla/application-services/issues/305 lands + val successUrl = "${parsedUri.scheme}://${parsedUri.host}/$successPath" + return RequestInterceptor.InterceptionResponse.Url(successUrl) + } + } +} diff --git a/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt new file mode 100644 index 00000000000..600982ee9c1 --- /dev/null +++ b/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.accounts + +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import mozilla.components.feature.tabs.TabsUseCases +import mozilla.components.service.fxa.AccountStorage +import mozilla.components.service.fxa.Config +import mozilla.components.service.fxa.FirefoxAccountShaped +import mozilla.components.service.fxa.FxaAccountManager +import mozilla.components.service.fxa.FxaNetworkException +import mozilla.components.service.fxa.Profile +import mozilla.components.service.fxa.SharedPrefAccountStorage +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Test + +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +// Same as the actual account manager, except we get to control how FirefoxAccountShaped instances +// are created. This is necessary because due to some build issues (native dependencies not available +// within the test environment) we can't use fxaclient supplied implementation of FirefoxAccountShaped. +// Instead, we express all of our account-related operations over an interface. +class TestableFxaAccountManager( + context: Context, + config: Config, + scopes: Array, + accountStorage: AccountStorage = SharedPrefAccountStorage(context), + val block: () -> FirefoxAccountShaped = { mock() } +) : FxaAccountManager(context, config, scopes, accountStorage) { + override fun createAccount(config: Config): FirefoxAccountShaped { + return block() + } +} + +@RunWith(RobolectricTestRunner::class) +class FirefoxAccountsAuthFeatureTest { + @Test + fun `begin authentication`() { + val accountStorage = mock() + val mockAccount: FirefoxAccountShaped = mock() + + val profile = Profile( + uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + + Mockito.`when`(mockAccount.getProfile(ArgumentMatchers.anyBoolean())).thenReturn(CompletableDeferred(profile)) + Mockito.`when`(mockAccount.beginOAuthFlow(any(), ArgumentMatchers.anyBoolean())) + .thenReturn(CompletableDeferred("auth://url")) + // This ceremony is necessary because CompletableDeferred() is created in an _active_ state, + // and threads will deadlock since it'll never be resolved while state machine is waiting for it. + // So we manually complete it here! + val unitDeferred = CompletableDeferred() + unitDeferred.complete(Unit) + Mockito.`when`(mockAccount.completeOAuthFlow( + ArgumentMatchers.anyString(), ArgumentMatchers.anyString()) + ).thenReturn(unitDeferred) + // There's no account at the start. + Mockito.`when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + RuntimeEnvironment.application, + Config.release("dummyId", "bad://url"), + arrayOf("profile", "test-scope"), + accountStorage + ) { + mockAccount + } + + runBlocking { + manager.init().await() + } + + val mockAddTab: TabsUseCases.AddNewTabUseCase = mock() + val mockTabs: TabsUseCases = mock() + `when`(mockTabs.addTab).thenReturn(mockAddTab) + + runBlocking { + val feature = FirefoxAccountsAuthFeature( + manager, mockTabs, "somePath", "somePath", this.coroutineContext) + feature.beginAuthentication() + } + + verify(mockAddTab, times(1)).invoke("auth://url") + Unit + } + + @Test + fun `begin authentication with errors`() { + val accountStorage = mock() + val mockAccount: FirefoxAccountShaped = mock() + + val profile = Profile( + uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + + Mockito.`when`(mockAccount.getProfile(ArgumentMatchers.anyBoolean())).thenReturn(CompletableDeferred(profile)) + + val exceptionalDeferred = CompletableDeferred() + exceptionalDeferred.completeExceptionally(FxaNetworkException("oops")) + Mockito.`when`(mockAccount.beginOAuthFlow(any(), ArgumentMatchers.anyBoolean())) + .thenReturn(exceptionalDeferred) + // This ceremony is necessary because CompletableDeferred() is created in an _active_ state, + // and threads will deadlock since it'll never be resolved while state machine is waiting for it. + // So we manually complete it here! + val unitDeferred = CompletableDeferred() + unitDeferred.complete(Unit) + Mockito.`when`(mockAccount.completeOAuthFlow( + ArgumentMatchers.anyString(), ArgumentMatchers.anyString()) + ).thenReturn(unitDeferred) + // There's no account at the start. + Mockito.`when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + RuntimeEnvironment.application, + Config.release("dummyId", "bad://url"), + arrayOf("profile", "test-scope"), + accountStorage + ) { + mockAccount + } + + runBlocking { + manager.init().await() + } + + val mockAddTab: TabsUseCases.AddNewTabUseCase = mock() + val mockTabs: TabsUseCases = mock() + `when`(mockTabs.addTab).thenReturn(mockAddTab) + + runBlocking { + val feature = FirefoxAccountsAuthFeature( + manager, mockTabs, "somePath", "somePath", this.coroutineContext) + feature.beginAuthentication() + } + + // Fallback url is invoked. + verify(mockAddTab, times(1)).invoke("https://accounts.firefox.com/signin") + Unit + } +} \ No newline at end of file diff --git a/components/feature/accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/feature/accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/feature/accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/components/feature/tabs/build.gradle b/components/feature/tabs/build.gradle index cc05a4aad46..118262e2b92 100644 --- a/components/feature/tabs/build.gradle +++ b/components/feature/tabs/build.gradle @@ -24,7 +24,7 @@ android { dependencies { - implementation project(':browser-session') + api project(':browser-session') implementation project(':concept-engine') implementation project(':concept-tabstray') implementation project(':concept-toolbar')