diff --git a/components/browser/state/build.gradle b/components/browser/state/build.gradle index 58c8bea9c31..27781da982a 100644 --- a/components/browser/state/build.gradle +++ b/components/browser/state/build.gradle @@ -12,6 +12,8 @@ android { defaultConfig { minSdkVersion config.minSdkVersion targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -21,6 +23,15 @@ android { } } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerVersion = Versions.kotlin + kotlinCompilerExtensionVersion = Versions.compose_version + } + packagingOptions { exclude 'META-INF/proguard/androidx-annotations.pro' } @@ -38,6 +49,7 @@ dependencies { api project(':concept-fetch') implementation Dependencies.androidx_browser + implementation Dependencies.androidx_compose_ui implementation Dependencies.kotlin_coroutines implementation Dependencies.kotlin_stdlib @@ -47,6 +59,10 @@ dependencies { testImplementation Dependencies.testing_junit testImplementation Dependencies.testing_mockito testImplementation Dependencies.testing_robolectric + + androidTestImplementation Dependencies.androidx_test_junit + androidTestImplementation Dependencies.androidx_compose_ui_test_manifest + androidTestImplementation Dependencies.androidx_compose_ui_test } apply from: '../../../publish.gradle' diff --git a/components/browser/state/src/androidTest/java/mozilla/components/browser/state/helper/OnDeviceTargetTabTest.kt b/components/browser/state/src/androidTest/java/mozilla/components/browser/state/helper/OnDeviceTargetTabTest.kt new file mode 100644 index 00000000000..f945eff2bb1 --- /dev/null +++ b/components/browser/state/src/androidTest/java/mozilla/components/browser/state/helper/OnDeviceTargetTabTest.kt @@ -0,0 +1,179 @@ +/* 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.browser.state.helper + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.CustomTabListAction +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createCustomTab +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * On-device tests for [TargetTab]. + */ +@RunWith(AndroidJUnit4::class) +class OnDeviceTargetTabTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun observingSelectedTab() { + val store = BrowserStore() + + val target = TargetTab.Selected + var observedTabId: String? = null + + rule.setContent { + val state = target.observeAsStateFrom( + store = store, + observe = { tab -> tab?.id } + ) + observedTabId = state.value?.id + } + + assertNull(observedTabId) + + store.dispatchBlockingOnIdle( + TabListAction.AddTabAction(createTab("https://www.mozilla.org", id = "mozilla")) + ) + + rule.runOnIdle { + assertEquals("mozilla", observedTabId) + } + + store.dispatchBlockingOnIdle( + TabListAction.AddTabAction(createTab("https://example.org", id = "example")) + ) + + rule.runOnIdle { + assertEquals("mozilla", observedTabId) + } + + store.dispatchBlockingOnIdle( + TabListAction.SelectTabAction("example") + ) + + rule.runOnIdle { + assertEquals("example", observedTabId) + } + + store.dispatchBlockingOnIdle( + TabListAction.RemoveTabAction("example") + ) + + rule.runOnIdle { + assertEquals("mozilla", observedTabId) + } + + store.dispatchBlockingOnIdle(TabListAction.RemoveAllTabsAction) + + rule.runOnIdle { + assertNull(observedTabId) + } + } + + @Test + fun observingPinnedTab() { + val store = BrowserStore( + initialState = BrowserState( + tabs = listOf( + createTab("https://www.mozilla.org", id = "mozilla"), + createTab("https://www.example.org", id = "example") + ), + selectedTabId = "mozilla" + ) + ) + + val target = TargetTab.Pinned("mozilla") + var observedTabId: String? = null + + rule.setContent { + val state = target.observeAsStateFrom( + store = store, + observe = { tab -> tab?.id } + ) + observedTabId = state.value?.id + } + + assertEquals("mozilla", observedTabId) + + store.dispatchBlockingOnIdle(TabListAction.SelectTabAction("example")) + + rule.runOnIdle { + assertEquals("mozilla", observedTabId) + } + + store.dispatchBlockingOnIdle(TabListAction.RemoveTabAction("mozilla")) + + rule.runOnIdle { + assertNull(observedTabId) + } + } + + @Test + fun observingCustomTab() { + val store = BrowserStore( + initialState = BrowserState( + tabs = listOf( + createTab("https://www.mozilla.org", id = "mozilla"), + createTab("https://www.example.org", id = "example") + ), + customTabs = listOf( + createCustomTab("https://www.reddit.com/r/firefox/", id = "reddit") + ), + selectedTabId = "mozilla" + ) + ) + + val target = TargetTab.Custom("reddit") + + var observedTabId: String? = null + + rule.setContent { + val state = target.observeAsStateFrom( + store = store, + observe = { tab -> tab?.id } + ) + observedTabId = state.value?.id + } + + assertEquals("reddit", observedTabId) + + store.dispatchBlockingOnIdle(TabListAction.SelectTabAction("example")) + + rule.runOnIdle { + assertEquals("reddit", observedTabId) + } + + store.dispatchBlockingOnIdle(TabListAction.RemoveTabAction("mozilla")) + + rule.runOnIdle { + assertEquals("reddit", observedTabId) + } + + store.dispatchBlockingOnIdle(CustomTabListAction.RemoveCustomTabAction("reddit")) + + rule.runOnIdle { + assertNull(observedTabId) + } + } + + private fun BrowserStore.dispatchBlockingOnIdle(action: BrowserAction) { + rule.runOnIdle { + val job = dispatch(action) + runBlocking { job.join() } + } + } +} diff --git a/components/browser/state/src/main/java/mozilla/components/browser/state/helper/TargetTab.kt b/components/browser/state/src/main/java/mozilla/components/browser/state/helper/TargetTab.kt new file mode 100644 index 00000000000..f3699e0dead --- /dev/null +++ b/components/browser/state/src/main/java/mozilla/components/browser/state/helper/TargetTab.kt @@ -0,0 +1,84 @@ +/* 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.browser.state.helper + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.observeAsState + +/** + * Helper for allowing a component consumer to specify which tab a component should target (e.g. + * the selected tab, a specific pinned tab or a custom tab). Additional helper methods make it + * easier to lookup the current state of the tab or observe changes. + */ +sealed class TargetTab { + /** + * Looks up this target in the given [BrowserStore] and returns the matching [SessionState] if + * available. Otherwise returns `null`. + */ + fun lookupIn(store: BrowserStore): SessionState? = lookupIn(store.state) + + /** + * Looks up this target in the given [BrowserState] and returns the matching [SessionState] if + * available. Otherwise returns `null`. + */ + abstract fun lookupIn(state: BrowserState): SessionState? + + /** + * Observes this target and represents the mapped state (using [map]) via [State]. + * + * Everytime the [Store] state changes and the result of the [observe] function changes for this + * state, the returned [State] will be updated causing recomposition of every [State.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + */ + @Composable + fun observeAsStateFrom( + store: BrowserStore, + observe: (SessionState?) -> R + ): State { + return store.observeAsState( + map = { state -> lookupIn(state) }, + observe = { state -> observe(lookupIn(state)) } + ) + } + + /** + * Targets the selected tab. + */ + object Selected : TargetTab() { + override fun lookupIn(state: BrowserState): SessionState? { + return state.selectedTab + } + } + + /** + * Targets a specific tab by its [tabId]. + */ + class Pinned(val tabId: String) : TargetTab() { + override fun lookupIn(state: BrowserState): SessionState? { + return state.findTab(tabId) + } + } + + /** + * Targets a specific custom tab by its [customTabId]. + */ + class Custom(val customTabId: String) : TargetTab() { + override fun lookupIn(state: BrowserState): SessionState? { + return state.findCustomTab(customTabId) + } + } +} diff --git a/components/browser/state/src/test/java/mozilla/components/browser/state/helper/TargetTabTest.kt b/components/browser/state/src/test/java/mozilla/components/browser/state/helper/TargetTabTest.kt new file mode 100644 index 00000000000..ac64aed6dba --- /dev/null +++ b/components/browser/state/src/test/java/mozilla/components/browser/state/helper/TargetTabTest.kt @@ -0,0 +1,138 @@ +/* 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.browser.state.helper + +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createCustomTab +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TargetTabTest { + @Test + fun lookupInStore() { + val store = BrowserStore( + initialState = BrowserState( + tabs = listOf( + createTab("https://www.mozilla.org", id = "mozilla"), + createTab("https://www.example.org", id = "example"), + createTab("https://theverge.com", id = "theverge", private = true) + ), + customTabs = listOf( + createCustomTab("https://www.reddit.com/r/firefox/", id = "reddit") + ), + selectedTabId = "mozilla" + ) + ) + + assertEquals( + "https://www.mozilla.org", + TargetTab.Selected.lookupIn(store)?.content?.url + ) + + assertEquals( + "https://www.mozilla.org", + TargetTab.Pinned("mozilla").lookupIn(store)?.content?.url + ) + + assertEquals( + "https://theverge.com", + TargetTab.Pinned("theverge").lookupIn(store)?.content?.url + ) + + assertNull( + TargetTab.Pinned("unknown").lookupIn(store) + ) + + assertNull( + TargetTab.Pinned("reddit").lookupIn(store) + ) + + assertEquals( + "https://www.reddit.com/r/firefox/", + TargetTab.Custom("reddit").lookupIn(store)?.content?.url + ) + + assertNull( + TargetTab.Custom("unknown").lookupIn(store) + ) + + assertNull( + TargetTab.Custom("mozilla").lookupIn(store) + ) + + store.dispatch( + TabListAction.SelectTabAction("example") + ).joinBlocking() + + assertEquals( + "https://www.example.org", + TargetTab.Selected.lookupIn(store)?.content?.url + ) + + store.dispatch( + TabListAction.RemoveAllTabsAction + ).joinBlocking() + + assertNull( + TargetTab.Selected.lookupIn(store) + ) + } + + @Test + fun lookupInState() { + val state = BrowserState( + tabs = listOf( + createTab("https://www.mozilla.org", id = "mozilla"), + createTab("https://www.example.org", id = "example"), + createTab("https://theverge.com", id = "theverge", private = true) + ), + customTabs = listOf( + createCustomTab("https://www.reddit.com/r/firefox/", id = "reddit") + ), + selectedTabId = "mozilla" + ) + + assertEquals( + "https://www.mozilla.org", + TargetTab.Selected.lookupIn(state)?.content?.url + ) + + assertEquals( + "https://www.mozilla.org", + TargetTab.Pinned("mozilla").lookupIn(state)?.content?.url + ) + + assertEquals( + "https://theverge.com", + TargetTab.Pinned("theverge").lookupIn(state)?.content?.url + ) + + assertNull( + TargetTab.Pinned("unknown").lookupIn(state) + ) + + assertNull( + TargetTab.Pinned("reddit").lookupIn(state) + ) + + assertEquals( + "https://www.reddit.com/r/firefox/", + TargetTab.Custom("reddit").lookupIn(state)?.content?.url + ) + + assertNull( + TargetTab.Custom("unknown").lookupIn(state) + ) + + assertNull( + TargetTab.Custom("mozilla").lookupIn(state) + ) + } +}