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')