summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/session
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/feature/session
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/session')
-rw-r--r--mobile/android/android-components/components/feature/session/README.md67
-rw-r--r--mobile/android/android-components/components/feature/session/build.gradle56
-rw-r--r--mobile/android/android-components/components/feature/session/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/CoordinateScrollingFeature.kt72
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/FullScreenFeature.kt97
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/HistoryDelegate.kt40
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt89
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/ScreenOrientationFeature.kt37
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionFeature.kt67
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionUseCases.kt544
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SettingsUseCases.kt56
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SwipeRefreshFeature.kt92
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt194
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/engine/EngineViewPresenter.kt92
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/LastAccessMiddleware.kt78
-rw-r--r--mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/undo/UndoMiddleware.kt153
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/CoordinateScrollingFeatureTest.kt89
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/FullScreenFeatureTest.kt465
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/HistoryDelegateTest.kt191
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt317
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/ScreenOrientationFeatureTest.kt56
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionFeatureTest.kt401
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionUseCasesTest.kt519
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SettingsUseCasesTest.kt73
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt130
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt266
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/LastAccessMiddlewareTest.kt319
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/undo/UndoMiddlewareTest.kt294
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/session/src/test/resources/robolectric.properties1
31 files changed, 4882 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/session/README.md b/mobile/android/android-components/components/feature/session/README.md
new file mode 100644
index 0000000000..785420bfa5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/README.md
@@ -0,0 +1,67 @@
+# [Android Components](../../../README.md) > Feature > Session
+
+A component that connects an (concept) engine implementation with the browser session module.
+A HistoryTrackingDelegate implementation is also provided, which allows tying together
+an engine implementation with a storage module.
+
+## Usage
+
+### 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-session:{latest-version}"
+```
+
+### SwipeRefreshFeature
+Sample code can be found in [Sample Browser app](https://github.com/mozilla-mobile/android-components/tree/main/samples/browser).
+
+Class to add pull to refresh functionality to browsers. You should pass it a reference to a [`SwipeRefreshLayout`](https://developer.android.com/reference/kotlin/androidx/swiperefreshlayout/widget/SwipeRefreshLayout.html) and the SessionManager.
+
+Your layout should have a `SwipeRefreshLayout` with an `EngineView` as its only child view.
+
+```xml
+<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+ android:id="@+id/swipeRefreshLayout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <mozilla.components.concept.engine.EngineView
+ android:id="@+id/engineView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
+```
+
+In your fragment code, use `SwipeRefreshFeature` to connect the `SwipeRefreshLayout` with your `SessionManager` and `ReloadUrlUseCase`.
+
+```kotlin
+ val feature = BrowserSwipeRefresh(sessionManager, sessionUseCases.reload, swipeRefreshLayout)
+ lifecycle.addObserver(feature)
+```
+
+`SwipeRefreshFeature` provides its own [`SwipeRefreshLayout.OnChildScrollUpCallback`](https://developer.android.com/reference/kotlin/androidx/swiperefreshlayout/widget/SwipeRefreshLayout.OnChildScrollUpCallback.html) and [`SwipeRefreshLayout.OnRefreshListener`](https://developer.android.com/reference/kotlin/androidx/swiperefreshlayout/widget/SwipeRefreshLayout.OnRefreshListener.html) implementations that you should not override.
+
+### ThumbnailsFeature
+
+Feature implementation for automatically taking thumbnails of sites. The feature will take a screenshot when the page finishes loading, and will add it to the `Session.thumbnail` property.
+
+```kotlin
+ val feature = ThumbnailsFeature(context, engineView, sessionManager)
+ lifecycle.addObserver(feature)
+```
+
+If the OS is under low memory conditions, the screenshot will be not taken. Ideally, this should be used in conjunction with [SessionManager.onLowMemory](https://github.com/mozilla-mobile/android-components/blob/024e3de456e3b46e9bf6718db9500ecc52da3d29/components/browser/session/src/main/java/mozilla/components/browser/session/SessionManager.kt#L472) to allow free up some `Session.thumbnail` from memory.
+
+ ```kotlin
+ // Wherever you implement ComponentCallbacks2
+ override fun onTrimMemory(level: Int) {
+ sessionManager.onLowMemory()
+ }
+```
+
+## 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/mobile/android/android-components/components/feature/session/build.gradle b/mobile/android/android-components/components/feature/session/build.gradle
new file mode 100644
index 0000000000..3844ecba77
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/build.gradle
@@ -0,0 +1,56 @@
+/* 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/. */
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.feature.session'
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions.freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+}
+
+dependencies {
+ implementation project(':browser-state')
+ implementation project(':concept-storage')
+ implementation project(':concept-toolbar')
+ implementation project(':concept-engine')
+ implementation project(':support-utils')
+ implementation project(':support-ktx')
+
+
+ implementation ComponentsDependencies.androidx_core_ktx
+ implementation ComponentsDependencies.androidx_swiperefreshlayout
+ implementation ComponentsDependencies.google_material
+
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-fakes')
+ testImplementation project(':support-test-libstate')
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.androidx_browser
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/feature/session/proguard-rules.pro b/mobile/android/android-components/components/feature/session/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/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/mobile/android/android-components/components/feature/session/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/session/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/CoordinateScrollingFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/CoordinateScrollingFeature.kt
new file mode 100644
index 0000000000..3d88f88c40
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/CoordinateScrollingFeature.kt
@@ -0,0 +1,72 @@
+/* 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.session
+
+import android.view.View
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
+import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
+import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
+import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * Feature implementation for connecting an [EngineView] with any View that you want to coordinate scrolling
+ * behavior with.
+ *
+ * A use case could be collapsing a toolbar every time that the user scrolls.
+ */
+class CoordinateScrollingFeature(
+ private val store: BrowserStore,
+ private val engineView: EngineView,
+ private val view: View,
+ private val scrollFlags: Int = DEFAULT_SCROLL_FLAGS,
+) : LifecycleAwareFeature {
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Start feature: Starts adding scrolling behavior for the indicated view.
+ */
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.mapNotNull { state -> state.selectedTab }
+ .map { tab -> tab.content.loading }
+ .distinctUntilChanged()
+ .collect { onLoadingStateChanged() }
+ }
+ }
+
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ private fun onLoadingStateChanged() {
+ val params = view.layoutParams as AppBarLayout.LayoutParams
+
+ if (engineView.canScrollVerticallyDown()) {
+ params.scrollFlags = scrollFlags
+ } else {
+ params.scrollFlags = 0
+ }
+
+ view.layoutParams = params
+ }
+
+ companion object {
+ const val DEFAULT_SCROLL_FLAGS = SCROLL_FLAG_SCROLL or
+ SCROLL_FLAG_ENTER_ALWAYS or
+ SCROLL_FLAG_SNAP or
+ SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/FullScreenFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/FullScreenFeature.kt
new file mode 100644
index 0000000000..0e87260201
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/FullScreenFeature.kt
@@ -0,0 +1,97 @@
+/* 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.session
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.base.feature.UserInteractionHandler
+
+/**
+ * Feature implementation for handling fullscreen mode (exiting and back button presses).
+ */
+open class FullScreenFeature(
+ private val store: BrowserStore,
+ private val sessionUseCases: SessionUseCases,
+ private val tabId: String? = null,
+ private val viewportFitChanged: (Int) -> Unit = {},
+ private val fullScreenChanged: (Boolean) -> Unit,
+) : LifecycleAwareFeature, UserInteractionHandler {
+ private var scope: CoroutineScope? = null
+ private var observation: Observation = createDefaultObservation()
+
+ /**
+ * Starts the feature and a observer to listen for fullscreen changes.
+ */
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
+ .map { tab -> tab.toObservation() }
+ .distinctUntilChanged()
+ .collect { observation -> onChange(observation) }
+ }
+ }
+
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ private fun onChange(observation: Observation) {
+ if (observation.inFullScreen != this.observation.inFullScreen) {
+ fullScreenChanged(observation.inFullScreen)
+ }
+
+ if (observation.layoutInDisplayCutoutMode != this.observation.layoutInDisplayCutoutMode) {
+ viewportFitChanged(observation.layoutInDisplayCutoutMode)
+ }
+
+ this.observation = observation
+ }
+
+ /**
+ * To be called when the back button is pressed, so that only fullscreen mode closes.
+ *
+ * @return Returns true if the fullscreen mode was successfully exited; false if no effect was taken.
+ */
+ override fun onBackPressed(): Boolean {
+ val observation = observation
+
+ if (observation.inFullScreen && observation.tabId != null) {
+ sessionUseCases.exitFullscreen(observation.tabId)
+ return true
+ }
+
+ return false
+ }
+}
+
+/**
+ * Simple holder data class to keep a reference to the last values we observed.
+ */
+private data class Observation(
+ val tabId: String?,
+ val inFullScreen: Boolean,
+ val layoutInDisplayCutoutMode: Int,
+)
+
+private fun SessionState?.toObservation(): Observation {
+ return if (this != null) {
+ Observation(id, content.fullScreen, content.layoutInDisplayCutoutMode)
+ } else {
+ createDefaultObservation()
+ }
+}
+
+private fun createDefaultObservation() = Observation(
+ tabId = null,
+ inFullScreen = false,
+ layoutInDisplayCutoutMode = 0,
+)
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/HistoryDelegate.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/HistoryDelegate.kt
new file mode 100644
index 0000000000..293aa76b70
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/HistoryDelegate.kt
@@ -0,0 +1,40 @@
+/* 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.session
+
+import mozilla.components.concept.engine.history.HistoryTrackingDelegate
+import mozilla.components.concept.storage.HistoryStorage
+import mozilla.components.concept.storage.PageObservation
+import mozilla.components.concept.storage.PageVisit
+
+/**
+ * Implementation of the [HistoryTrackingDelegate] which delegates work to an instance of [HistoryStorage].
+ */
+class HistoryDelegate(private val historyStorage: Lazy<HistoryStorage>) : HistoryTrackingDelegate {
+ override suspend fun onVisited(uri: String, visit: PageVisit) {
+ historyStorage.value.recordVisit(uri, visit)
+ }
+
+ override suspend fun onTitleChanged(uri: String, title: String) {
+ historyStorage.value.recordObservation(uri, PageObservation(title = title))
+ }
+
+ override suspend fun onPreviewImageChange(uri: String, previewImageUrl: String) {
+ historyStorage.value.recordObservation(
+ uri,
+ PageObservation(previewImageUrl = previewImageUrl),
+ )
+ }
+
+ override suspend fun getVisited(uris: List<String>): List<Boolean> {
+ return historyStorage.value.getVisited(uris)
+ }
+
+ override suspend fun getVisited(): List<String> {
+ return historyStorage.value.getVisited()
+ }
+
+ override fun shouldStoreUri(uri: String) = historyStorage.value.canAddUri(uri)
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt
new file mode 100644
index 0000000000..a65629957c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt
@@ -0,0 +1,89 @@
+/* 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.session
+
+import android.app.Activity
+import android.app.PictureInPictureParams
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import androidx.annotation.RequiresApi
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.support.base.log.logger.Logger
+
+/**
+ * A simple implementation of Picture-in-picture mode if on a supported platform.
+ *
+ * @param store Browser Store for observing the selected session's fullscreen mode changes.
+ * @param activity the activity with the EngineView for calling PIP mode when required; the AndroidX Fragment
+ * doesn't support this.
+ * @param crashReporting Instance of `CrashReporting` to record unexpected caught exceptions
+ * @param tabId ID of tab or custom tab session.
+ */
+class PictureInPictureFeature(
+ private val store: BrowserStore,
+ private val activity: Activity,
+ private val crashReporting: CrashReporting? = null,
+ private val tabId: String? = null,
+) {
+ internal val logger = Logger("PictureInPictureFeature")
+
+ private val hasSystemFeature = SDK_INT >= Build.VERSION_CODES.N &&
+ activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
+
+ fun onHomePressed(): Boolean {
+ if (!hasSystemFeature) {
+ return false
+ }
+
+ val session = store.state.findTabOrCustomTabOrSelectedTab(tabId)
+ val fullScreenMode = session?.content?.fullScreen == true
+ val contentIsPlaying = session?.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING
+ return fullScreenMode && contentIsPlaying && try {
+ enterPipModeCompat()
+ } catch (e: IllegalStateException) {
+ // On certain Samsung devices, if accessibility mode is enabled, this will throw an
+ // IllegalStateException even if we check for the system feature beforehand. So let's
+ // catch it, log it, and not enter PiP. See https://stackoverflow.com/q/55288858
+ logger.warn("Entering PipMode failed", e)
+ crashReporting?.submitCaughtException(e)
+ false
+ }
+ }
+
+ /**
+ * Enter Picture-in-Picture mode.
+ */
+ fun enterPipModeCompat() = when {
+ !hasSystemFeature -> false
+ SDK_INT >= Build.VERSION_CODES.O -> enterPipModeForO()
+ SDK_INT >= Build.VERSION_CODES.N -> enterPipModeForN()
+ else -> false
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun enterPipModeForO() =
+ activity.enterPictureInPictureMode(PictureInPictureParams.Builder().build())
+
+ @Suppress("Deprecation")
+ @RequiresApi(Build.VERSION_CODES.N)
+ private fun enterPipModeForN() = run {
+ activity.enterPictureInPictureMode()
+ true
+ }
+
+ /**
+ * Should be called when the system informs you of changes to and from picture-in-picture mode.
+ * @param enabled True if the activity is in picture-in-picture mode.
+ */
+ fun onPictureInPictureModeChanged(enabled: Boolean) {
+ val sessionId = tabId ?: store.state.selectedTabId ?: return
+ store.dispatch(ContentAction.PictureInPictureChangedAction(sessionId, enabled))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/ScreenOrientationFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/ScreenOrientationFeature.kt
new file mode 100644
index 0000000000..6b57b355c0
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/ScreenOrientationFeature.kt
@@ -0,0 +1,37 @@
+/* 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.session
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.activity.OrientationDelegate
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * Feature that automatically reacts to [Engine] requests of updating the app's screen orientation.
+ */
+class ScreenOrientationFeature(
+ private val engine: Engine,
+ private val activity: Activity,
+) : LifecycleAwareFeature, OrientationDelegate {
+ override fun start() {
+ engine.registerScreenOrientationDelegate(this)
+ }
+
+ override fun stop() {
+ engine.unregisterScreenOrientationDelegate()
+ }
+
+ override fun onOrientationLock(requestedOrientation: Int): Boolean {
+ activity.requestedOrientation = requestedOrientation
+ return true
+ }
+
+ override fun onOrientationUnlock() {
+ // As indicated by GeckoView - https://bugzilla.mozilla.org/show_bug.cgi?id=1744101#c3
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionFeature.kt
new file mode 100644
index 0000000000..9dae8d3aac
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionFeature.kt
@@ -0,0 +1,67 @@
+/* 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.session
+
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.feature.session.engine.EngineViewPresenter
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.base.feature.UserInteractionHandler
+
+/**
+ * Feature implementation for connecting the engine module with the session module.
+ */
+class SessionFeature(
+ private val store: BrowserStore,
+ private val goBackUseCase: SessionUseCases.GoBackUseCase,
+ private val engineView: EngineView,
+ private val tabId: String? = null,
+) : LifecycleAwareFeature, UserInteractionHandler {
+ internal val presenter = EngineViewPresenter(store, engineView, tabId)
+
+ /**
+ * Start feature: App is in the foreground.
+ */
+ override fun start() {
+ presenter.start()
+ }
+
+ /**
+ * Handler for back pressed events in activities that use this feature.
+ *
+ * @return true if the event was handled, otherwise false.
+ */
+ override fun onBackPressed(): Boolean {
+ val tab = store.state.findTabOrCustomTabOrSelectedTab(tabId)
+
+ if (engineView.canClearSelection()) {
+ engineView.clearSelection()
+ return true
+ } else if (tab?.content?.canGoBack == true) {
+ goBackUseCase(tab.id)
+ return true
+ }
+
+ return false
+ }
+
+ /**
+ * Stop feature: App is in the background.
+ */
+ override fun stop() {
+ presenter.stop()
+ }
+
+ /**
+ * Stops the feature from rendering sessions on the [EngineView] (until explicitly started again)
+ * and releases an already rendering session from the [EngineView].
+ */
+ fun release() {
+ // Once we fully migrated to BrowserStore we may be able to get rid of the need for cleanup().
+ // See https://github.com/mozilla-mobile/android-components/issues/7657
+ presenter.stop()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionUseCases.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionUseCases.kt
new file mode 100644
index 0000000000..565ba34632
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SessionUseCases.kt
@@ -0,0 +1,544 @@
+/* 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.session
+
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.CrashAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.LastAccessAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.TranslationsAction
+import mozilla.components.browser.state.selector.findTabOrCustomTab
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+import mozilla.components.concept.engine.translate.TranslationOptions
+
+/**
+ * Contains use cases related to the session feature.
+ */
+class SessionUseCases(
+ store: BrowserStore,
+ onNoTab: (String) -> TabSessionState = { url ->
+ createTab(url).apply { store.dispatch(TabListAction.AddTabAction(this)) }
+ },
+) {
+
+ /**
+ * Contract for use cases that load a provided URL.
+ */
+ interface LoadUrlUseCase {
+ /**
+ * Loads the provided URL using the currently selected session.
+ */
+ fun invoke(
+ url: String,
+ flags: LoadUrlFlags = LoadUrlFlags.none(),
+ additionalHeaders: Map<String, String>? = null,
+ )
+ }
+
+ class DefaultLoadUrlUseCase internal constructor(
+ private val store: BrowserStore,
+ private val onNoTab: (String) -> TabSessionState,
+ ) : LoadUrlUseCase {
+
+ /**
+ * Loads the provided URL using the currently selected session. If
+ * there's no selected session a new session will be created using
+ * [onNoTab].
+ *
+ * @param url The URL to be loaded using the selected session.
+ * @param flags The [LoadUrlFlags] to use when loading the provided url.
+ * @param additionalHeaders the extra headers to use when loading the provided url.
+ */
+ override operator fun invoke(
+ url: String,
+ flags: LoadUrlFlags,
+ additionalHeaders: Map<String, String>?,
+ ) {
+ this.invoke(url, store.state.selectedTabId, flags, additionalHeaders)
+ }
+
+ /**
+ * Loads the provided URL using the specified session. If no session
+ * is provided the currently selected session will be used. If there's
+ * no selected session a new session will be created using [onNoTab].
+ *
+ * @param url The URL to be loaded using the provided session.
+ * @param sessionId the ID of the session for which the URL should be loaded.
+ * @param flags The [LoadUrlFlags] to use when loading the provided url.
+ * @param additionalHeaders the extra headers to use when loading the provided url.
+ */
+ operator fun invoke(
+ url: String,
+ sessionId: String? = null,
+ flags: LoadUrlFlags = LoadUrlFlags.none(),
+ additionalHeaders: Map<String, String>? = null,
+ ) {
+ val loadSessionId = sessionId
+ ?: store.state.selectedTabId
+ ?: onNoTab.invoke(url).id
+
+ val tab = store.state.findTabOrCustomTab(loadSessionId)
+ val engineSession = tab?.engineState?.engineSession
+
+ // If we already have an engine session load Url directly to prevent
+ // context switches.
+ if (engineSession != null) {
+ val parentEngineSession = if (tab is TabSessionState) {
+ tab.parentId?.let { store.state.findTabOrCustomTab(it)?.engineState?.engineSession }
+ } else {
+ null
+ }
+ engineSession.loadUrl(
+ url = url,
+ parent = parentEngineSession,
+ flags = flags,
+ additionalHeaders = additionalHeaders,
+ )
+ // Update the url in content immediately until the engine updates with any new changes to the state.
+ store.dispatch(
+ ContentAction.UpdateUrlAction(
+ loadSessionId,
+ url,
+ ),
+ )
+ store.dispatch(
+ EngineAction.OptimizedLoadUrlTriggeredAction(
+ loadSessionId,
+ url,
+ flags,
+ additionalHeaders,
+ ),
+ )
+ } else {
+ store.dispatch(
+ EngineAction.LoadUrlAction(
+ loadSessionId,
+ url,
+ flags,
+ additionalHeaders,
+ ),
+ )
+ }
+ }
+ }
+
+ class LoadDataUseCase internal constructor(
+ private val store: BrowserStore,
+ private val onNoTab: (String) -> TabSessionState,
+ ) {
+ /**
+ * Loads the provided data based on the mime type using the provided session (or the
+ * currently selected session if none is provided).
+ */
+ operator fun invoke(
+ data: String,
+ mimeType: String,
+ encoding: String = "UTF-8",
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ val loadTabId = tabId ?: onNoTab.invoke("about:blank").id
+
+ store.dispatch(
+ EngineAction.LoadDataAction(
+ loadTabId,
+ data,
+ mimeType,
+ encoding,
+ ),
+ )
+ }
+ }
+
+ class ReloadUrlUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Reloads the current URL of the provided session (or the currently
+ * selected session if none is provided).
+ *
+ * @param tabId the ID of the tab for which the reload should be triggered.
+ * @param flags the [LoadUrlFlags] to use when reloading the given session.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ flags: LoadUrlFlags = LoadUrlFlags.none(),
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.ReloadAction(
+ tabId,
+ flags,
+ ),
+ )
+ }
+ }
+
+ class StopLoadingUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Stops the current URL of the provided session from loading.
+ *
+ * @param tabId the ID of the tab for which loading should be stopped.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.state.findTabOrCustomTab(tabId)
+ ?.engineState
+ ?.engineSession
+ ?.stopLoading()
+ }
+ }
+
+ class GoBackUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Navigates back in the history of the currently selected tab
+ *
+ * @param userInteraction informs the engine whether the action was user invoked.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ userInteraction: Boolean = true,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.GoBackAction(
+ tabId,
+ userInteraction,
+ ),
+ )
+ }
+ }
+
+ class GoForwardUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Navigates forward in the history of the currently selected session
+ *
+ * @param userInteraction informs the engine whether the action was user invoked.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ userInteraction: Boolean = true,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.GoForwardAction(
+ tabId,
+ userInteraction,
+ ),
+ )
+ }
+ }
+
+ /**
+ * Use case to jump to an arbitrary history index in a session's backstack.
+ */
+ class GoToHistoryIndexUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Navigates to a specific index in the [HistoryState] of the given session.
+ * Invalid index values will be ignored.
+ *
+ * @param index the index in the session's [HistoryState] to navigate to.
+ * @param session the session whose [HistoryState] is being accessed, defaulting
+ * to the selected session.
+ */
+ operator fun invoke(
+ index: Int,
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.GoToHistoryIndexAction(
+ tabId,
+ index,
+ ),
+ )
+ }
+ }
+
+ class RequestDesktopSiteUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Requests the desktop version of the current session and reloads the page.
+ */
+ operator fun invoke(
+ enable: Boolean,
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.ToggleDesktopModeAction(
+ tabId,
+ enable,
+ ),
+ )
+ }
+ }
+
+ class ExitFullScreenUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Exits fullscreen mode of the current session.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ EngineAction.ExitFullScreenModeAction(
+ tabId,
+ ),
+ )
+ }
+ }
+
+ /**
+ * Tries to recover from a crash by restoring the last know state.
+ */
+ class CrashRecoveryUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Tries to recover the state of all crashed sessions.
+ */
+ fun invoke() {
+ val tabIds = store.state.let {
+ it.tabs + it.customTabs
+ }.filter {
+ it.engineState.crashed
+ }.map {
+ it.id
+ }
+
+ return invoke(tabIds)
+ }
+
+ /**
+ * Tries to recover the state of all sessions.
+ */
+ fun invoke(tabIds: List<String>) {
+ tabIds.forEach { tabId ->
+ store.dispatch(
+ CrashAction.RestoreCrashedSessionAction(tabId),
+ )
+ }
+ }
+ }
+
+ /**
+ * UseCase for purging the (back and forward) history of all tabs and custom tabs.
+ */
+ class PurgeHistoryUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Purges the (back and forward) history of all tabs and custom tabs.
+ */
+ operator fun invoke() {
+ store.dispatch(EngineAction.PurgeHistoryAction)
+ }
+ }
+
+ /**
+ * Sets the [TabSessionState.lastAccess] timestamp of the provided tab. This timestamp
+ * is updated automatically by our EngineViewPresenter and LastAccessMiddleware, but
+ * there are app-specific flows where this can't happen automatically e.g., the app
+ * being resumed to the home screen despite having a selected tab. In this case, the app
+ * may want to update the last access timestamp of the selected tab.
+ *
+ * It will likely make sense to support finer-grained timestamps in the future so applications
+ * can differentiate viewing from tab selection for instance.s
+ */
+ class UpdateLastAccessUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Updates [TabSessionState.lastAccess] of the tab with the provided ID. Note that this
+ * method has no effect in case the tab doesn't exist or is a custom tab.
+ *
+ * @param tabId the ID of the tab to update, defaults to the ID of the currently selected tab.
+ * @param lastAccess the timestamp to set [TabSessionState.lastAccess] to, defaults to now.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ lastAccess: Long = System.currentTimeMillis(),
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(
+ LastAccessAction.UpdateLastAccessAction(
+ tabId,
+ lastAccess,
+ ),
+ )
+ }
+ }
+
+ /**
+ * A use case for requesting a given tab to generate a PDF from it's content.
+ */
+ class SaveToPdfUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Request a PDF to be generated from the given [tabId].
+ *
+ * If the tab is not loaded, [BrowserStore] will ensure the session has been created and
+ * loaded, however, this does not guarantee the page contents will be correctly painted
+ * into the PDF. Typically, a session is required to have been painted on the screen (by
+ * being the selected tab) for a PDF to be generated successfully.
+ *
+ * ⚠️ Make sure to have a middleware that handles the [EngineAction.SaveToPdfExceptionAction]`,
+ * or your application will crash when an error happens when
+ * requesting a page to be saved a PDF.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(EngineAction.SaveToPdfAction(tabId))
+ }
+ }
+
+ /**
+ * A use case for requesting a given tab to print it's content.
+ */
+ class PrintContentUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Request that Android print the current [tabId].
+ *
+ * The same caveats in the [SaveToPdfUseCase] apply here because the Engine makes a PDF prior
+ * to sending the print request on to the Android print spooler. This means the session should
+ * have been painted first to successfully make a PDF.
+ *
+ * ⚠️ Make sure to have a middleware that handles the [EngineAction.PrintContentExceptionAction]`
+ * to handle print errors. Handling [EngineAction.PrintContentCompletedAction] is only necessary for
+ * telemetry or if any extra actions need to be completed.
+ *
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+
+ store.dispatch(EngineAction.PrintContentAction(tabId))
+ }
+ }
+
+ /**
+ * A use case for requesting a given tab's content to be translated.
+ */
+ class TranslateUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Request that Android translate the content of the current [tabId].
+ *
+ * Typically, a session is required to have been painted on the screen (by
+ * being the selected tab) for a translation to occur successfully.
+ *
+ * @param tabId The [tabId] associated with the request.
+ * @param fromLanguage The BCP 47 language tag that the page should be translated from.
+ * @param toLanguage The BCP 47 language tag that the page should be translated to.
+ * @param options Options for how the translation should be processed.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ fromLanguage: String,
+ toLanguage: String,
+ options: TranslationOptions?,
+ ) {
+ if (tabId == null) {
+ return
+ }
+ store.dispatch(TranslationsAction.TranslateAction(tabId, fromLanguage, toLanguage, options))
+ }
+ }
+
+ /**
+ * A use case for requesting a given tab's content be restored after a translation.
+ */
+ class TranslateRestoreUseCase internal constructor(
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Request that the translations engine restore the translated content of the current
+ * [tabId] back to the original.
+ *
+ * Will be a no-op, if there is nothing to restore.
+ *
+ * @param tabId The [tabId] associated with the request.
+ */
+ operator fun invoke(
+ tabId: String? = store.state.selectedTabId,
+ ) {
+ if (tabId == null) {
+ return
+ }
+ store.dispatch(TranslationsAction.TranslateRestoreAction(tabId))
+ }
+ }
+
+ val loadUrl: DefaultLoadUrlUseCase by lazy { DefaultLoadUrlUseCase(store, onNoTab) }
+ val loadData: LoadDataUseCase by lazy { LoadDataUseCase(store, onNoTab) }
+ val reload: ReloadUrlUseCase by lazy { ReloadUrlUseCase(store) }
+ val stopLoading: StopLoadingUseCase by lazy { StopLoadingUseCase(store) }
+ val goBack: GoBackUseCase by lazy { GoBackUseCase(store) }
+ val goForward: GoForwardUseCase by lazy { GoForwardUseCase(store) }
+ val goToHistoryIndex: GoToHistoryIndexUseCase by lazy { GoToHistoryIndexUseCase(store) }
+ val requestDesktopSite: RequestDesktopSiteUseCase by lazy { RequestDesktopSiteUseCase(store) }
+ val exitFullscreen: ExitFullScreenUseCase by lazy { ExitFullScreenUseCase(store) }
+ val saveToPdf: SaveToPdfUseCase by lazy { SaveToPdfUseCase(store) }
+ val printContent: PrintContentUseCase by lazy { PrintContentUseCase(store) }
+ val translate: TranslateUseCase by lazy { TranslateUseCase(store) }
+ val translateRestore: TranslateRestoreUseCase by lazy { TranslateRestoreUseCase(store) }
+ val crashRecovery: CrashRecoveryUseCase by lazy { CrashRecoveryUseCase(store) }
+ val purgeHistory: PurgeHistoryUseCase by lazy { PurgeHistoryUseCase(store) }
+ val updateLastAccess: UpdateLastAccessUseCase by lazy { UpdateLastAccessUseCase(store) }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SettingsUseCases.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SettingsUseCases.kt
new file mode 100644
index 0000000000..0183cf728c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SettingsUseCases.kt
@@ -0,0 +1,56 @@
+/* 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.session
+
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
+import mozilla.components.concept.engine.Settings
+
+/**
+ * Contains use cases related to engine [Settings].
+ *
+ * @param engine reference to the application's browser [Engine].
+ * @param store the application's [BrowserStore].
+ */
+class SettingsUseCases(
+ engine: Engine,
+ store: BrowserStore,
+) {
+ /**
+ * Updates the tracking protection policy to the given policy value when invoked.
+ * All active sessions are automatically updated with the new policy.
+ */
+ class UpdateTrackingProtectionUseCase internal constructor(
+ private val engine: Engine,
+ private val store: BrowserStore,
+ ) {
+ /**
+ * Updates the tracking protection policy for all current and future [EngineSession]
+ * instances.
+ */
+ operator fun invoke(policy: TrackingProtectionPolicy) {
+ engine.settings.trackingProtectionPolicy = policy
+
+ store.state.forEachEngineSession { engineSession ->
+ engineSession.updateTrackingProtection(policy)
+ }
+
+ engine.clearSpeculativeSession()
+ }
+ }
+
+ val updateTrackingProtection: UpdateTrackingProtectionUseCase by lazy {
+ UpdateTrackingProtectionUseCase(engine, store)
+ }
+}
+
+private fun BrowserState.forEachEngineSession(block: (EngineSession) -> Unit) {
+ (tabs + customTabs)
+ .mapNotNull { it.engineState.engineSession }
+ .map { block(it) }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SwipeRefreshFeature.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SwipeRefreshFeature.kt
new file mode 100644
index 0000000000..4668777378
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/SwipeRefreshFeature.kt
@@ -0,0 +1,92 @@
+/* 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.session
+
+import android.view.View
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.action.ContentAction.UpdateRefreshCanceledStateAction
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
+
+/**
+ * Feature implementation to add pull to refresh functionality to browsers.
+ *
+ * @param swipeRefreshLayout Reference to SwipeRefreshLayout that has an [EngineView] as its child.
+ */
+class SwipeRefreshFeature(
+ private val store: BrowserStore,
+ private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase,
+ private val swipeRefreshLayout: SwipeRefreshLayout,
+ private val onRefreshCallback: (() -> Unit)? = null,
+ private val tabId: String? = null,
+) : LifecycleAwareFeature,
+ SwipeRefreshLayout.OnChildScrollUpCallback,
+ SwipeRefreshLayout.OnRefreshListener {
+ private var scope: CoroutineScope? = null
+
+ init {
+ swipeRefreshLayout.setOnRefreshListener(this)
+ swipeRefreshLayout.setOnChildScrollUpCallback(this)
+ }
+
+ /**
+ * Start feature: Starts adding pull to refresh behavior for the active session.
+ */
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
+ .ifAnyChanged {
+ arrayOf(it?.content?.loading, it?.content?.refreshCanceled)
+ }
+ .collect { tab ->
+ tab?.let {
+ if (!tab.content.loading || tab.content.refreshCanceled) {
+ swipeRefreshLayout.isRefreshing = false
+ if (tab.content.refreshCanceled) {
+ // In case the user tries to refresh again
+ // we need to reset refreshCanceled, to be able to
+ // get a subsequent event.
+ store.dispatch(UpdateRefreshCanceledStateAction(tab.id, false))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ /**
+ * Callback that checks whether it is possible for the child view to scroll up.
+ * If the child view cannot scroll up and the scroll event is not handled by the webpage
+ * it means we need to trigger the pull down to refresh functionality.
+ */
+ @Suppress("Deprecation")
+ override fun canChildScrollUp(parent: SwipeRefreshLayout, child: View?) =
+ if (child is EngineView) {
+ !child.getInputResultDetail().canOverscrollTop()
+ } else {
+ true
+ }
+
+ /**
+ * Called when a swipe gesture triggers a refresh.
+ */
+ override fun onRefresh() {
+ onRefreshCallback?.invoke()
+ store.state.findTabOrCustomTabOrSelectedTab(tabId)?.let { tab ->
+ reloadUrlUseCase(tab.id)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt
new file mode 100644
index 0000000000..403885d68d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/TrackingProtectionUseCases.kt
@@ -0,0 +1,194 @@
+/* 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.session
+
+import androidx.core.net.toUri
+import mozilla.components.browser.state.action.TrackingProtectionAction
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.content.blocking.TrackerLog
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionException
+import mozilla.components.support.base.log.logger.Logger
+import java.lang.Exception
+
+/**
+ * Contains use cases related to the tracking protection.
+ *
+ * @param store the application's [BrowserStore].
+ * @param engine the application's [Engine].
+ */
+class TrackingProtectionUseCases(
+ val store: BrowserStore,
+ val engine: Engine,
+) {
+
+ /**
+ * Use case for adding a new tab to the exception list.
+ */
+ class AddExceptionUseCase internal constructor(
+ private val store: BrowserStore,
+ private val engine: Engine,
+ ) {
+ private val logger = Logger("TrackingProtectionUseCases")
+
+ /**
+ * Adds a new tab to the exception list, as a result this tab will not get applied any
+ * tracking protection policy.
+ * @param tabId The id of the tab that will be added to the exception list.
+ * @param persistInPrivateMode Indicates if the exception should be persistent in private mode
+ * defaults to false.
+ */
+ operator fun invoke(tabId: String, persistInPrivateMode: Boolean = false) {
+ val engineSession = store.state.findTabOrCustomTabOrSelectedTab(tabId)?.engineState?.engineSession
+ ?: return logger.error("The engine session should not be null")
+
+ engine.trackingProtectionExceptionStore.add(engineSession, persistInPrivateMode)
+ }
+ }
+
+ /**
+ * Use case for removing a tab or a [TrackingProtectionException] from the exception list.
+ */
+ class RemoveExceptionUseCase internal constructor(
+ private val store: BrowserStore,
+ private val engine: Engine,
+ ) {
+ private val logger = Logger("TrackingProtectionUseCases")
+
+ /**
+ * Removes a tab from the exception list.
+ * @param tabId The id of the tab that will be removed from the exception list.
+ */
+ operator fun invoke(tabId: String) {
+ val engineSession = store.state.findTabOrCustomTabOrSelectedTab(tabId)?.engineState?.engineSession
+ ?: return logger.error("The engine session should not be null")
+
+ engine.trackingProtectionExceptionStore.remove(engineSession)
+ }
+
+ /**
+ * Removes a [exception] from the exception list.
+ * @param exception The [TrackingProtectionException] that will be removed from the exception list.
+ */
+ operator fun invoke(exception: TrackingProtectionException) {
+ engine.trackingProtectionExceptionStore.remove(exception)
+ // Find all tabs that need to update their tracking protection status.
+ val tabs = (store.state.tabs + store.state.customTabs).filter { tab ->
+ val tabDomain = tab.content.url.toUri().host
+ val exceptionDomain = exception.url.toUri().host
+ tabDomain == exceptionDomain
+ }
+ tabs.forEach {
+ store.dispatch(TrackingProtectionAction.ToggleExclusionListAction(it.id, false))
+ }
+ }
+ }
+
+ /**
+ * Use case for removing all tabs from the exception list.
+ */
+ class RemoveAllExceptionsUseCase internal constructor(
+ private val store: BrowserStore,
+ private val engine: Engine,
+ ) {
+ /**
+ * Removes all domains from the exception list.
+ */
+ operator fun invoke(onRemove: () -> Unit = {}) {
+ val engineSessions = (store.state.tabs + store.state.customTabs).mapNotNull { tab ->
+ tab.engineState.engineSession
+ }
+
+ engine.trackingProtectionExceptionStore.removeAll(engineSessions, onRemove)
+ }
+ }
+
+ /**
+ * Use case for verifying if a tab is in the exception list.
+ */
+ class ContainsExceptionUseCase internal constructor(
+ private val store: BrowserStore,
+ private val engine: Engine,
+ ) {
+ /**
+ * Indicates if a given tab is in the exception list.
+ * @param tabId The id of the tab to verify.
+ * @param onResult A callback to inform if the given tab is on
+ * the exception list, true if it is in otherwise false.
+ */
+ operator fun invoke(
+ tabId: String,
+ onResult: (Boolean) -> Unit,
+ ) {
+ val engineSession = store.state.findTabOrCustomTabOrSelectedTab(tabId)?.engineState?.engineSession
+ ?: return onResult(false)
+
+ engine.trackingProtectionExceptionStore.contains(engineSession, onResult)
+ }
+ }
+
+ /**
+ * Use case for fetching all exceptions in the exception list.
+ */
+ class FetchExceptionsUseCase internal constructor(
+ private val engine: Engine,
+ ) {
+ /**
+ * Fetch all domains that will be ignored for tracking protection.
+ * @param onResult A callback to inform that the domains on the exception list has been fetched,
+ * it provides a list of [TrackingProtectionException] that are on the exception list, if there are none domains
+ * on the exception list, an empty list will be provided.
+ */
+ operator fun invoke(onResult: (List<TrackingProtectionException>) -> Unit) {
+ engine.trackingProtectionExceptionStore.fetchAll(onResult)
+ }
+ }
+
+ /**
+ * Use case for fetching all the tracking protection logged information.
+ */
+ class FetchTrackingLogUserCase internal constructor(
+ private val store: BrowserStore,
+ private val engine: Engine,
+ ) {
+ /**
+ * Fetch all the tracking protection logged information of a given tab.
+ *
+ * @param tabId the id of the tab for which loading should be stopped.
+ * @param onSuccess callback invoked if the data was fetched successfully.
+ * @param onError (optional) callback invoked if fetching the data caused an exception.
+ */
+ operator fun invoke(
+ tabId: String,
+ onSuccess: (List<TrackerLog>) -> Unit,
+ onError: (Throwable) -> Unit,
+ ) {
+ val engineSession = store.state.findTabOrCustomTabOrSelectedTab(tabId)?.engineState?.engineSession
+ ?: return onError(Exception("The engine session should not be null"))
+
+ engine.getTrackersLog(engineSession, onSuccess, onError)
+ }
+ }
+
+ val fetchTrackingLogs: FetchTrackingLogUserCase by lazy {
+ FetchTrackingLogUserCase(store, engine)
+ }
+ val addException: AddExceptionUseCase by lazy {
+ AddExceptionUseCase(store, engine)
+ }
+ val removeException: RemoveExceptionUseCase by lazy {
+ RemoveExceptionUseCase(store, engine)
+ }
+ val containsException: ContainsExceptionUseCase by lazy {
+ ContainsExceptionUseCase(store, engine)
+ }
+ val removeAllExceptions: RemoveAllExceptionsUseCase by lazy {
+ RemoveAllExceptionsUseCase(store, engine)
+ }
+ val fetchExceptions: FetchExceptionsUseCase by lazy {
+ FetchExceptionsUseCase(engine)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/engine/EngineViewPresenter.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/engine/EngineViewPresenter.kt
new file mode 100644
index 0000000000..af440358da
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/engine/EngineViewPresenter.kt
@@ -0,0 +1,92 @@
+/* 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.session.engine
+
+import android.view.View
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.LastAccessAction
+import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
+
+/**
+ * Presenter implementation for EngineView.
+ */
+internal class EngineViewPresenter(
+ private val store: BrowserStore,
+ private val engineView: EngineView,
+ private val tabId: String?,
+) {
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Start presenter and display data in view.
+ */
+ fun start() {
+ scope = store.flowScoped { flow ->
+ flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
+ // Render if the tab itself changed and when an engine session is linked
+ .ifAnyChanged { tab ->
+ arrayOf(
+ tab?.id,
+ tab?.engineState?.engineSession,
+ tab?.engineState?.crashed,
+ tab?.content?.firstContentfulPaint,
+ )
+ }
+ .collect { tab -> onTabToRender(tab) }
+ }
+ }
+
+ /**
+ * Stop presenter from updating view.
+ */
+ fun stop() {
+ scope?.cancel()
+ engineView.release()
+ }
+
+ private fun onTabToRender(tab: SessionState?) {
+ if (tab == null) {
+ engineView.release()
+ } else {
+ renderTab(tab)
+ }
+ }
+
+ private fun renderTab(tab: SessionState) {
+ val engineSession = tab.engineState.engineSession
+
+ val actualView = engineView.asView()
+
+ if (tab.engineState.crashed) {
+ engineView.release()
+ return
+ }
+
+ if (tab.content.firstContentfulPaint) {
+ actualView.visibility = View.VISIBLE
+ }
+
+ if (engineSession == null) {
+ // This tab does not have an EngineSession that we can render yet. Let's dispatch an
+ // action to request creating one. Once one was created and linked to this session, this
+ // method will get invoked again.
+ store.dispatch(EngineAction.CreateEngineSessionAction(tab.id))
+ } else {
+ // Since we render the tab again let's update its last access flag. In the future, we
+ // may need more fine-grained flags to differentiate viewing from tab selection.
+ store.dispatch(LastAccessAction.UpdateLastAccessAction(tab.id, System.currentTimeMillis()))
+ engineView.render(engineSession)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/LastAccessMiddleware.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/LastAccessMiddleware.kt
new file mode 100644
index 0000000000..c1a90dc0d1
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/LastAccessMiddleware.kt
@@ -0,0 +1,78 @@
+/* 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.session.middleware
+
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.LastAccessAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.MiddlewareContext
+
+/**
+ * [Middleware] that handles updating the [TabSessionState.lastAccess] when a tab is selected.
+ */
+class LastAccessMiddleware : Middleware<BrowserState, BrowserAction> {
+ override fun invoke(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ next: (BrowserAction) -> Unit,
+ action: BrowserAction,
+ ) {
+ // Since tab removal can affect tab selection we save the
+ // selected tab ID before removal to determine if it changed.
+ val selectionBeforeRemoval = when (action) {
+ is TabListAction.RemoveTabAction,
+ is TabListAction.RemoveTabsAction,
+ // NB: RemoveAllNormalTabsAction and RemoveAllPrivateTabsAction never update tab selection
+ -> {
+ context.state.selectedTabId
+ }
+ else -> null
+ }
+
+ next(action)
+
+ when (action) {
+ is TabListAction.RemoveTabAction,
+ is TabListAction.RemoveTabsAction,
+ // NB: RemoveAllNormalTabsAction and RemoveAllPrivateTabsAction never updates tab selection
+ -> {
+ // If the selected tab changed during removal we make sure to update
+ // the lastAccess state of the newly selected tab.
+ val newSelection = context.state.selectedTabId
+ if (newSelection != null && newSelection != selectionBeforeRemoval) {
+ context.dispatchUpdateActionForId(newSelection)
+ }
+ }
+ is TabListAction.SelectTabAction -> {
+ context.dispatchUpdateActionForId(action.tabId)
+ }
+ is TabListAction.AddTabAction -> {
+ if (action.select) {
+ context.dispatchUpdateActionForId(action.tab.id)
+ }
+ }
+ is TabListAction.RestoreAction -> {
+ action.selectedTabId?.let {
+ context.dispatchUpdateActionForId(it)
+ }
+ }
+ is ContentAction.UpdateUrlAction -> {
+ if (action.sessionId == context.state.selectedTabId) {
+ context.dispatchUpdateActionForId(action.sessionId)
+ }
+ }
+ else -> {
+ // no-op
+ }
+ }
+ }
+
+ private fun MiddlewareContext<BrowserState, BrowserAction>.dispatchUpdateActionForId(id: String) {
+ dispatch(LastAccessAction.UpdateLastAccessAction(id, System.currentTimeMillis()))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/undo/UndoMiddleware.kt b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/undo/UndoMiddleware.kt
new file mode 100644
index 0000000000..694f1d67d0
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/middleware/undo/UndoMiddleware.kt
@@ -0,0 +1,153 @@
+/* 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.session.middleware.undo
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.UndoAction
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.selector.normalTabs
+import mozilla.components.browser.state.selector.privateTabs
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.toRecoverableTab
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.MiddlewareContext
+import mozilla.components.lib.state.Store
+import mozilla.components.support.base.log.logger.Logger
+import java.util.UUID
+import mozilla.components.support.base.coroutines.Dispatchers as MozillaDispatchers
+
+/**
+ * [Middleware] implementation that adds removed tabs to [BrowserState.undoHistory] for a short
+ * amount of time ([clearAfterMillis]). Dispatching [UndoAction.RestoreRecoverableTabs] will restore
+ * the tabs from [BrowserState.undoHistory].
+ */
+class UndoMiddleware(
+ private val clearAfterMillis: Long = 5000, // For comparison: a LENGTH_LONG Snackbar takes 2750.
+ private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main),
+ private val waitScope: CoroutineScope = CoroutineScope(MozillaDispatchers.Cached),
+) : Middleware<BrowserState, BrowserAction> {
+ private val logger = Logger("UndoMiddleware")
+ private var clearJob: Job? = null
+
+ override fun invoke(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ next: (BrowserAction) -> Unit,
+ action: BrowserAction,
+ ) {
+ val state = context.state
+
+ when (action) {
+ // Remember removed tabs
+ is TabListAction.RemoveAllNormalTabsAction -> onTabsRemoved(
+ context,
+ state.normalTabs,
+ state.selectedTabId,
+ )
+ is TabListAction.RemoveAllPrivateTabsAction -> onTabsRemoved(
+ context,
+ state.privateTabs,
+ state.selectedTabId,
+ )
+ is TabListAction.RemoveAllTabsAction -> {
+ if (action.recoverable) {
+ onTabsRemoved(context, state.tabs, state.selectedTabId)
+ }
+ }
+ is TabListAction.RemoveTabAction -> state.findTab(action.tabId)?.let {
+ onTabsRemoved(context, listOf(it), state.selectedTabId)
+ }
+ is TabListAction.RemoveTabsAction -> {
+ action.tabIds.mapNotNull { state.findTab(it) }.let {
+ onTabsRemoved(context, it, state.selectedTabId)
+ }
+ }
+
+ // Restore
+ is UndoAction.RestoreRecoverableTabs -> restore(context.store, context.state)
+
+ // Do nothing when an action different from above is passed in.
+ else -> { }
+ }
+
+ next(action)
+ }
+
+ private fun onTabsRemoved(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ tabs: List<SessionState>,
+ selectedTabId: String?,
+ ) {
+ clearJob?.cancel()
+
+ val recoverableTabs = mutableListOf<RecoverableTab>()
+ tabs.forEach { tab ->
+ if (tab is TabSessionState) {
+ val index = context.state.tabs.indexOfFirst { it.id == tab.id }
+ recoverableTabs.add(tab.toRecoverableTab(index))
+ }
+ }
+
+ if (recoverableTabs.isEmpty()) {
+ logger.debug("No recoverable tabs to add to undo history.")
+ return
+ }
+
+ val tag = UUID.randomUUID().toString()
+
+ val selectionToRestore = selectedTabId?.let {
+ recoverableTabs.find { it.state.id == selectedTabId }?.state?.id
+ }
+
+ context.dispatch(
+ UndoAction.AddRecoverableTabs(tag, recoverableTabs, selectionToRestore),
+ )
+
+ val store = context.store
+
+ clearJob = waitScope.launch {
+ delay(clearAfterMillis)
+ store.dispatch(UndoAction.ClearRecoverableTabs(tag))
+ }
+ }
+
+ private fun restore(
+ store: Store<BrowserState, BrowserAction>,
+ state: BrowserState,
+ ) = mainScope.launch {
+ clearJob?.cancel()
+
+ // Since we have to restore into SessionManager (until we can nuke it from orbit and only use BrowserStore),
+ // this is a bit crude. For example we do not restore into the previous position. The goal is to make this
+ // nice once we can restore directly into BrowserState.
+
+ val undoHistory = state.undoHistory
+ val tabs = undoHistory.tabs
+ if (tabs.isEmpty()) {
+ logger.debug("No recoverable tabs for undo.")
+ return@launch
+ }
+
+ store.dispatch(
+ TabListAction.RestoreAction(
+ tabs,
+ restoreLocation = TabListAction.RestoreAction.RestoreLocation.AT_INDEX,
+ ),
+ )
+
+ // Restore the previous selection if needed.
+ undoHistory.selectedTabId?.let { tabId ->
+ store.dispatch(TabListAction.SelectTabAction(tabId))
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/CoordinateScrollingFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/CoordinateScrollingFeatureTest.kt
new file mode 100644
index 0000000000..f4442a9d23
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/CoordinateScrollingFeatureTest.kt
@@ -0,0 +1,89 @@
+/* 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.session
+
+import android.os.Looper.getMainLooper
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.material.appbar.AppBarLayout
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.feature.session.CoordinateScrollingFeature.Companion.DEFAULT_SCROLL_FLAGS
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.any
+import org.mockito.Mockito.verify
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class CoordinateScrollingFeatureTest {
+
+ private lateinit var scrollFeature: CoordinateScrollingFeature
+ private lateinit var mockEngineView: EngineView
+ private lateinit var mockView: View
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setup() {
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ ),
+ selectedTabId = "mozilla",
+ ),
+ )
+
+ mockEngineView = mock()
+ mockView = mock()
+ scrollFeature = CoordinateScrollingFeature(store, mockEngineView, mockView)
+
+ whenever(mockView.layoutParams).thenReturn(mock<AppBarLayout.LayoutParams>())
+ }
+
+ @Test
+ fun `when session loading StateChanged and engine canScrollVertically is false must remove scrollFlags`() {
+ scrollFeature.start()
+ shadowOf(getMainLooper()).idle()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("mozilla", true)).joinBlocking()
+
+ verify((mockView.layoutParams as AppBarLayout.LayoutParams)).scrollFlags = 0
+ verify(mockView).layoutParams = any()
+ }
+
+ @Test
+ fun `when session loading StateChanged and engine canScrollVertically is true must add DEFAULT_SCROLL_FLAGS `() {
+ whenever(mockEngineView.canScrollVerticallyDown()).thenReturn(true)
+
+ scrollFeature.start()
+ shadowOf(getMainLooper()).idle()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("mozilla", true)).joinBlocking()
+
+ verify((mockView.layoutParams as AppBarLayout.LayoutParams)).scrollFlags = DEFAULT_SCROLL_FLAGS
+ verify(mockView).layoutParams = any()
+ }
+
+ @Test
+ fun `when session loading StateChanged and engine canScrollVertically is true must add custom scrollFlags`() {
+ whenever(mockEngineView.canScrollVerticallyDown()).thenReturn(true)
+ scrollFeature = CoordinateScrollingFeature(store, mockEngineView, mockView, 12)
+ scrollFeature.start()
+ shadowOf(getMainLooper()).idle()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("mozilla", true)).joinBlocking()
+
+ verify((mockView.layoutParams as AppBarLayout.LayoutParams)).scrollFlags = 12
+ verify(mockView).layoutParams = any()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/FullScreenFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/FullScreenFeatureTest.kt
new file mode 100644
index 0000000000..ca95692827
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/FullScreenFeatureTest.kt
@@ -0,0 +1,465 @@
+/* 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.session
+
+import android.view.WindowManager
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+class FullScreenFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `Starting without tabs`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore()
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+ store.waitUntilIdle()
+
+ assertNull(viewPort)
+ assertNull(fullscreen)
+ }
+
+ @Test
+ fun `Starting with selected tab will not invoke callbacks with default state`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+ store.waitUntilIdle()
+
+ assertNull(viewPort)
+ assertNull(fullscreen)
+ }
+
+ @Test
+ fun `Starting with selected tab`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "A",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "A",
+ 42,
+ ),
+ ).joinBlocking()
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+ store.waitUntilIdle()
+
+ assertEquals(42, viewPort)
+ assertTrue(fullscreen!!)
+ }
+
+ @Test
+ fun `Selected tab switching to fullscreen mode`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "A",
+ true,
+ ),
+ ).joinBlocking()
+
+ assertNull(viewPort)
+ assertTrue(fullscreen!!)
+ }
+
+ @Test
+ fun `Selected tab changing viewport`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "A",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "A",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
+ ),
+ ).joinBlocking()
+
+ assertNotEquals(0, viewPort)
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, viewPort)
+ assertTrue(fullscreen!!)
+ }
+
+ @Test
+ fun `Fixed tab switching to fullscreen mode and back`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.firefox.com", id = "B"),
+ createTab("https://getpocket.com", id = "C"),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = "B",
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "B",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "B",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
+ ),
+ ).joinBlocking()
+
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, viewPort)
+ assertTrue(fullscreen!!)
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "B",
+ false,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "B",
+ 0,
+ ),
+ ).joinBlocking()
+
+ assertEquals(0, viewPort)
+ assertFalse(fullscreen!!)
+ }
+
+ @Test
+ fun `Callback functions no longer get invoked when stopped, but get new value on next start`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.firefox.com", id = "B"),
+ createTab("https://getpocket.com", id = "C"),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = "B",
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "B",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "B",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER,
+ ),
+ ).joinBlocking()
+
+ feature.start()
+
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER, viewPort)
+ assertTrue(fullscreen!!)
+
+ feature.stop()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "B",
+ false,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "B",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
+ ),
+ ).joinBlocking()
+
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER, viewPort)
+ assertTrue(fullscreen!!)
+
+ feature.start()
+
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, viewPort)
+ assertFalse(fullscreen!!)
+ }
+
+ @Test
+ fun `onBackPressed will invoke usecase for active fullscreen mode`() {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.firefox.com", id = "B"),
+ createTab("https://getpocket.com", id = "C"),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ val exitUseCase: SessionUseCases.ExitFullScreenUseCase = mock()
+ val useCases: SessionUseCases = mock()
+ doReturn(exitUseCase).`when`(useCases).exitFullscreen
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = useCases,
+ tabId = "B",
+ fullScreenChanged = {},
+ )
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "B",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "B",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
+ ),
+ ).joinBlocking()
+
+ assertTrue(feature.onBackPressed())
+
+ verify(exitUseCase).invoke("B")
+ }
+
+ @Test
+ fun `Fullscreen tab gets removed`() {
+ var viewPort: Int? = null
+ var fullscreen: Boolean? = null
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = mock(),
+ tabId = null,
+ viewportFitChanged = { value -> viewPort = value },
+ fullScreenChanged = { value -> fullscreen = value },
+ )
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.FullScreenChangedAction(
+ "A",
+ true,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ ContentAction.ViewportFitChangedAction(
+ "A",
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
+ ),
+ ).joinBlocking()
+
+ assertEquals(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, viewPort)
+ assertTrue(fullscreen!!)
+
+ store.dispatch(
+ TabListAction.RemoveTabAction(tabId = "A"),
+ ).joinBlocking()
+
+ assertEquals(0, viewPort)
+ assertFalse(fullscreen!!)
+ }
+
+ @Test
+ fun `onBackPressed will not invoke usecase if not in fullscreen mode`() {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.firefox.com", id = "B"),
+ createTab("https://getpocket.com", id = "C"),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ val exitUseCase: SessionUseCases.ExitFullScreenUseCase = mock()
+ val useCases: SessionUseCases = mock()
+ doReturn(exitUseCase).`when`(useCases).exitFullscreen
+
+ val feature = FullScreenFeature(
+ store = store,
+ sessionUseCases = useCases,
+ fullScreenChanged = {},
+ )
+
+ feature.start()
+
+ assertFalse(feature.onBackPressed())
+
+ verify(exitUseCase, never()).invoke("B")
+ }
+
+ @Test
+ fun `onBackPressed getting invoked without any tabs to observe`() {
+ val exitUseCase: SessionUseCases.ExitFullScreenUseCase = mock()
+ val useCases: SessionUseCases = mock()
+ doReturn(exitUseCase).`when`(useCases).exitFullscreen
+
+ val feature = FullScreenFeature(
+ store = BrowserStore(),
+ sessionUseCases = useCases,
+ fullScreenChanged = {},
+ )
+
+ // Invoking onBackPressed without fullscreen mode
+ assertFalse(feature.onBackPressed())
+
+ verify(exitUseCase, never()).invoke(ArgumentMatchers.anyString())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/HistoryDelegateTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/HistoryDelegateTest.kt
new file mode 100644
index 0000000000..8506e345b5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/HistoryDelegateTest.kt
@@ -0,0 +1,191 @@
+/* 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.session
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.FrecencyThresholdOption
+import mozilla.components.concept.storage.HistoryStorage
+import mozilla.components.concept.storage.PageObservation
+import mozilla.components.concept.storage.PageVisit
+import mozilla.components.concept.storage.SearchResult
+import mozilla.components.concept.storage.TopFrecentSiteInfo
+import mozilla.components.concept.storage.VisitInfo
+import mozilla.components.concept.storage.VisitType
+import mozilla.components.concept.toolbar.AutocompleteProvider
+import mozilla.components.concept.toolbar.AutocompleteResult
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class HistoryDelegateTest {
+
+ @Test
+ fun `history delegate passes through onVisited calls`() = runTest {
+ val storage = mock<HistoryStorage>()
+ val delegate = HistoryDelegate(lazy { storage })
+
+ delegate.onVisited("http://www.mozilla.org", PageVisit(VisitType.LINK))
+ verify(storage).recordVisit("http://www.mozilla.org", PageVisit(VisitType.LINK))
+
+ delegate.onVisited("http://www.firefox.com", PageVisit(VisitType.RELOAD))
+ verify(storage).recordVisit("http://www.firefox.com", PageVisit(VisitType.RELOAD))
+
+ delegate.onVisited("http://www.firefox.com", PageVisit(VisitType.BOOKMARK))
+ verify(storage).recordVisit("http://www.firefox.com", PageVisit(VisitType.BOOKMARK))
+ }
+
+ @Test
+ fun `history delegate passes through onTitleChanged calls`() = runTest {
+ val storage = mock<HistoryStorage>()
+ val delegate = HistoryDelegate(lazy { storage })
+
+ delegate.onTitleChanged("http://www.mozilla.org", "Mozilla")
+ verify(storage).recordObservation("http://www.mozilla.org", PageObservation("Mozilla"))
+ }
+
+ @Test
+ fun `history delegate passes through onPreviewImageChange calls`() = runTest {
+ val storage = mock<HistoryStorage>()
+ val delegate = HistoryDelegate(lazy { storage })
+
+ val previewImageUrl = "https://test.com/og-image-url"
+ delegate.onPreviewImageChange("http://www.mozilla.org", previewImageUrl)
+ verify(storage).recordObservation(
+ "http://www.mozilla.org",
+ PageObservation(previewImageUrl = previewImageUrl),
+ )
+ }
+
+ @Test
+ fun `history delegate passes through getVisited calls`() = runTest {
+ val storage = TestHistoryStorage()
+ val delegate = HistoryDelegate(lazy { storage })
+
+ assertFalse(storage.getVisitedPlainCalled)
+ assertFalse(storage.getVisitedListCalled)
+ assertFalse(storage.canAddUriCalled)
+
+ delegate.getVisited()
+ assertTrue(storage.getVisitedPlainCalled)
+ assertFalse(storage.getVisitedListCalled)
+ assertFalse(storage.canAddUriCalled)
+
+ delegate.getVisited(listOf("http://www.mozilla.org", "http://www.firefox.com"))
+ assertTrue(storage.getVisitedListCalled)
+ assertFalse(storage.canAddUriCalled)
+ }
+
+ @Test
+ fun `history delegate checks with storage canAddUriCalled`() = runTest {
+ val storage = TestHistoryStorage()
+ val delegate = HistoryDelegate(lazy { storage })
+
+ assertFalse(storage.canAddUriCalled)
+ delegate.shouldStoreUri("test")
+ assertTrue(storage.canAddUriCalled)
+ }
+
+ private class TestHistoryStorage : HistoryStorage, AutocompleteProvider {
+ var getVisitedListCalled = false
+ var getVisitedPlainCalled = false
+ var canAddUriCalled = false
+
+ override suspend fun warmUp() {
+ fail()
+ }
+
+ override suspend fun recordVisit(uri: String, visit: PageVisit) {}
+
+ override suspend fun recordObservation(uri: String, observation: PageObservation) {}
+
+ override fun canAddUri(uri: String): Boolean {
+ canAddUriCalled = true
+ return true
+ }
+
+ override suspend fun getVisited(uris: List<String>): List<Boolean> {
+ getVisitedListCalled = true
+ assertEquals(listOf("http://www.mozilla.org", "http://www.firefox.com"), uris)
+ return emptyList()
+ }
+
+ override suspend fun getVisited(): List<String> {
+ getVisitedPlainCalled = true
+ return emptyList()
+ }
+
+ override suspend fun getDetailedVisits(start: Long, end: Long, excludeTypes: List<VisitType>): List<VisitInfo> {
+ fail()
+ return emptyList()
+ }
+
+ override suspend fun getVisitsPaginated(offset: Long, count: Long, excludeTypes: List<VisitType>): List<VisitInfo> {
+ fail()
+ return emptyList()
+ }
+
+ override suspend fun getTopFrecentSites(
+ numItems: Int,
+ frecencyThreshold: FrecencyThresholdOption,
+ ): List<TopFrecentSiteInfo> {
+ fail()
+ return emptyList()
+ }
+
+ override fun getSuggestions(query: String, limit: Int): List<SearchResult> {
+ fail()
+ return listOf()
+ }
+
+ override suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult? {
+ fail()
+ return null
+ }
+
+ override suspend fun deleteEverything() {
+ fail()
+ }
+
+ override suspend fun deleteVisitsSince(since: Long) {
+ fail()
+ }
+
+ override suspend fun deleteVisitsBetween(startTime: Long, endTime: Long) {
+ fail()
+ }
+
+ override suspend fun deleteVisitsFor(url: String) {
+ fail()
+ }
+
+ override suspend fun deleteVisit(url: String, timestamp: Long) {
+ fail()
+ }
+
+ override suspend fun runMaintenance(dbSizeLimit: UInt) {
+ fail()
+ }
+
+ override fun cleanup() {
+ fail()
+ }
+
+ override fun cancelWrites() {
+ fail()
+ }
+
+ override fun cancelReads() {
+ fail()
+ }
+
+ override fun cancelReads(nextQuery: String) {
+ fail()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt
new file mode 100644
index 0000000000..dd25a303dd
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt
@@ -0,0 +1,317 @@
+/* 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.session
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.MediaSessionState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoInteractions
+import org.mockito.verification.VerificationMode
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class PictureInPictureFeatureTest {
+
+ private val crashReporting: CrashReporting = mock()
+ private val activity: Activity = Mockito.mock(Activity::class.java, Mockito.RETURNS_DEEP_STUBS)
+
+ @Before
+ fun setUp() {
+ whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
+ .thenReturn(true)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.M])
+ fun `on home pressed without system feature on android m and lower`() {
+ val store = mock<BrowserStore>()
+ whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
+ .thenReturn(false)
+
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verifyNoInteractions(store)
+ verifyNoInteractions(activity.packageManager)
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `on home pressed without system feature on android n and above`() {
+ val store = mock<BrowserStore>()
+ whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
+ .thenReturn(false)
+
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verifyNoInteractions(store)
+ verify(activity.packageManager).hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed without a selected session`() {
+ val store = BrowserStore()
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(activity.packageManager).hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed with a selected session without a fullscreen mode`() {
+ val selectedSession = createTab("https://mozilla.org").copyWithFullScreen(false)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ assertFalse(selectedSession.content.fullScreen)
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed with a selected session in fullscreen without media playing and without pip mode`() {
+ val controller = mock<MediaSession.Controller>()
+ val selectedSession = createTab(
+ url = "https://mozilla.org",
+ mediaSessionState = MediaSessionState(
+ playbackState = MediaSession.PlaybackState.UNKNOWN,
+ controller = controller,
+ ),
+ ).copyWithFullScreen(true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ doReturn(false).`when`(pictureInPictureFeature).enterPipModeCompat()
+
+ assertFalse(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING)
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed with a selected session in fullscreen with media playing and without pip mode`() {
+ val controller = mock<MediaSession.Controller>()
+ val selectedSession = createTab(
+ url = "https://mozilla.org",
+ mediaSessionState = MediaSessionState(
+ playbackState = MediaSession.PlaybackState.PLAYING,
+ controller = controller,
+ ),
+ ).copyWithFullScreen(true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ doReturn(false).`when`(pictureInPictureFeature).enterPipModeCompat()
+
+ assertTrue(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING)
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(pictureInPictureFeature).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed with a selected session in fullscreen without media playing and with pip mode`() {
+ val controller = mock<MediaSession.Controller>()
+ val selectedSession = createTab(
+ url = "https://mozilla.org",
+ mediaSessionState = MediaSessionState(
+ playbackState = MediaSession.PlaybackState.UNKNOWN,
+ controller = controller,
+ ),
+ ).copyWithFullScreen(true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ doReturn(true).`when`(pictureInPictureFeature).enterPipModeCompat()
+
+ assertFalse(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING)
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(pictureInPictureFeature, never()).enterPipModeCompat()
+ }
+
+ @Test
+ fun `on home pressed with a selected session in fullscreen with media playing and with pip mode`() {
+ val controller = mock<MediaSession.Controller>()
+ val selectedSession = createTab(
+ url = "https://mozilla.org",
+ mediaSessionState = MediaSessionState(
+ playbackState = MediaSession.PlaybackState.PLAYING,
+ controller = controller,
+ ),
+ ).copyWithFullScreen(true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+ val pictureInPictureFeature =
+ spy(PictureInPictureFeature(store, activity, crashReporting))
+
+ doReturn(true).`when`(pictureInPictureFeature).enterPipModeCompat()
+
+ assertTrue(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING)
+ assertTrue(pictureInPictureFeature.onHomePressed())
+ verify(pictureInPictureFeature).enterPipModeCompat()
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.M])
+ fun `enter pip mode compat on android m and below`() {
+ val store = mock<BrowserStore>()
+ val pictureInPictureFeature = PictureInPictureFeature(store, activity, crashReporting)
+
+ assertFalse(pictureInPictureFeature.enterPipModeCompat())
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.O])
+ fun `enter pip mode compat without system feature on android o`() {
+ whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
+ .thenReturn(false)
+
+ val pictureInPictureFeature =
+ PictureInPictureFeature(mock(), activity, crashReporting)
+
+ assertFalse(pictureInPictureFeature.enterPipModeCompat())
+ verify(activity, never()).enterPictureInPictureMode(any())
+ verifyDeprecatedPictureInPictureMode(activity, never())
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.O])
+ fun `enter pip mode compat with system feature on android o but entering throws exception`() {
+ val controller = mock<MediaSession.Controller>()
+ val selectedSession = createTab(
+ url = "https://mozilla.org",
+ mediaSessionState = MediaSessionState(
+ playbackState = MediaSession.PlaybackState.PLAYING,
+ controller = controller,
+ ),
+ ).copyWithFullScreen(true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedSession),
+ selectedTabId = selectedSession.id,
+ ),
+ )
+
+ whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE))
+ .thenReturn(true)
+ whenever(activity.enterPictureInPictureMode(any())).thenThrow(IllegalStateException())
+
+ val pictureInPictureFeature =
+ PictureInPictureFeature(store, activity, crashReporting)
+ assertFalse(pictureInPictureFeature.onHomePressed())
+ verify(crashReporting).submitCaughtException(any<IllegalStateException>())
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.O])
+ fun `enter pip mode compat on android o and above`() {
+ val pictureInPictureFeature =
+ PictureInPictureFeature(mock(), activity, crashReporting)
+
+ whenever(activity.enterPictureInPictureMode(any())).thenReturn(true)
+
+ assertTrue(pictureInPictureFeature.enterPipModeCompat())
+ verify(activity).enterPictureInPictureMode(any())
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `enter pip mode compat on android n and above`() {
+ val pictureInPictureFeature =
+ PictureInPictureFeature(mock(), activity, crashReporting)
+
+ assertTrue(pictureInPictureFeature.enterPipModeCompat())
+ verifyDeprecatedPictureInPictureMode(activity)
+ }
+
+ @Test
+ fun `on pip mode changed`() {
+ val store = mock<BrowserStore>()
+ val pipFeature = PictureInPictureFeature(
+ store,
+ activity,
+ crashReporting,
+ tabId = "tab-id",
+ )
+
+ pipFeature.onPictureInPictureModeChanged(true)
+ verify(store).dispatch(ContentAction.PictureInPictureChangedAction("tab-id", true))
+
+ pipFeature.onPictureInPictureModeChanged(false)
+ verify(store).dispatch(ContentAction.PictureInPictureChangedAction("tab-id", false))
+
+ verify(activity, never()).enterPictureInPictureMode(any())
+ verifyDeprecatedPictureInPictureMode(activity, never())
+ }
+
+ @Suppress("Deprecation")
+ private fun verifyDeprecatedPictureInPictureMode(
+ activity: Activity,
+ mode: VerificationMode = times(1),
+ ) {
+ verify(activity, mode).enterPictureInPictureMode()
+ }
+
+ @Suppress("Unchecked_Cast")
+ private fun <T : SessionState> T.copyWithFullScreen(fullScreen: Boolean): T =
+ createCopy(content = content.copy(fullScreen = fullScreen)) as T
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/ScreenOrientationFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/ScreenOrientationFeatureTest.kt
new file mode 100644
index 0000000000..4135b6d39b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/ScreenOrientationFeatureTest.kt
@@ -0,0 +1,56 @@
+/* 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.session
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import mozilla.components.concept.engine.Engine
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class ScreenOrientationFeatureTest {
+ @Test
+ fun `WHEN the feature starts THEN register itself as a screen orientation delegate`() {
+ val engine = mock<Engine>()
+ val feature = ScreenOrientationFeature(engine, mock())
+
+ feature.start()
+
+ verify(engine).registerScreenOrientationDelegate(feature)
+ }
+
+ @Test
+ fun `WHEN the feature stops THEN unregister itself as the screen orientation delegate`() {
+ val engine = mock<Engine>()
+ val feature = ScreenOrientationFeature(engine, mock())
+
+ feature.stop()
+
+ verify(engine).unregisterScreenOrientationDelegate()
+ }
+
+ @Test
+ fun `WHEN asked to set a screen orientation THEN set it on the activity property and return true`() {
+ val activity = mock<Activity>()
+ val feature = ScreenOrientationFeature(mock(), activity)
+
+ val result = feature.onOrientationLock(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
+
+ verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ assertTrue(result)
+ }
+
+ @Test
+ fun `WHEN asked to reset screen orientation THEN set it to UNSPECIFIED`() {
+ val activity = mock<Activity>()
+ val feature = ScreenOrientationFeature(mock(), activity)
+
+ feature.onOrientationUnlock()
+
+ verify(activity).requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionFeatureTest.kt
new file mode 100644
index 0000000000..0192f04773
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionFeatureTest.kt
@@ -0,0 +1,401 @@
+/* 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.session
+
+import android.view.View
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.CrashAction
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.engine.EngineMiddleware
+import mozilla.components.browser.state.selector.findTab
+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.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.support.test.any
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+class SessionFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @Test
+ fun `start renders selected session`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ feature.start()
+
+ store.waitUntilIdle()
+ verify(view).render(engineSession)
+ }
+
+ @Test
+ fun `start renders fixed session`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("C", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view, tabId = "C")
+ verify(view, never()).render(any())
+
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+ }
+
+ @Test
+ fun `start renders custom tab session`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("D", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view, tabId = "D")
+ verify(view, never()).render(any())
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+ }
+
+ @Test
+ fun `renders selected tab after changes`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSessionA: EngineSession = mock()
+ val engineSessionB: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("A", engineSessionA)).joinBlocking()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSessionB)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ feature.start()
+ store.waitUntilIdle()
+ verify(view).render(engineSessionB)
+
+ store.dispatch(TabListAction.SelectTabAction("A")).joinBlocking()
+ store.waitUntilIdle()
+ verify(view).render(engineSessionA)
+ }
+
+ @Test
+ fun `creates engine session if needed`() {
+ val store = spy(prepareStore())
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ feature.start()
+ store.waitUntilIdle()
+ verify(store).dispatch(EngineAction.CreateEngineSessionAction("B"))
+ }
+
+ @Test
+ fun `does not render new selected session after stop`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSessionA: EngineSession = mock()
+ val engineSessionB: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("A", engineSessionA)).joinBlocking()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSessionB)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ feature.start()
+ store.waitUntilIdle()
+ verify(view).render(engineSessionB)
+
+ feature.stop()
+
+ store.dispatch(TabListAction.SelectTabAction("A")).joinBlocking()
+ store.waitUntilIdle()
+ verify(view, never()).render(engineSessionA)
+ }
+
+ @Test
+ fun `releases when last selected session gets removed`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSession)).joinBlocking()
+ val feature = SessionFeature(store, mock(), view)
+
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+ verify(view, never()).release()
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+ verify(view).release()
+ }
+
+ @Test
+ fun `release stops observing and releases session from view`() {
+ val store = prepareStore()
+ val actualView: View = mock()
+
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+
+ val newEngineSession: EngineSession = mock()
+ feature.release()
+ verify(view).release()
+
+ store.dispatch(TabListAction.SelectTabAction("A")).joinBlocking()
+ verify(view, never()).render(newEngineSession)
+ }
+
+ @Test
+ fun `releases when custom tab gets removed`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("D", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view, tabId = "D")
+ verify(view, never()).render(any())
+
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+ verify(view, never()).release()
+
+ store.dispatch(CustomTabListAction.RemoveCustomTabAction("D")).joinBlocking()
+ verify(view).release()
+ }
+
+ @Test
+ fun `onBackPressed clears selection if it exists`() {
+ run {
+ val view: EngineView = mock()
+ doReturn(false).`when`(view).canClearSelection()
+
+ val feature = SessionFeature(BrowserStore(), mock(), view)
+ assertFalse(feature.onBackPressed())
+
+ verify(view, never()).clearSelection()
+ }
+
+ run {
+ val view: EngineView = mock()
+ doReturn(true).`when`(view).canClearSelection()
+
+ val feature = SessionFeature(BrowserStore(), mock(), view)
+ assertTrue(feature.onBackPressed())
+
+ verify(view).clearSelection()
+ }
+ }
+
+ @Test
+ fun `onBackPressed() invokes GoBackUseCase if back navigation is possible`() {
+ run {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ val useCase: SessionUseCases.GoBackUseCase = mock()
+
+ val feature = SessionFeature(store, useCase, mock())
+
+ assertFalse(feature.onBackPressed())
+ verify(useCase, never()).invoke("A")
+ }
+
+ run {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "A")),
+ selectedTabId = "A",
+ ),
+ )
+
+ store.dispatch(
+ ContentAction.UpdateBackNavigationStateAction(
+ "A",
+ canGoBack = true,
+ ),
+ ).joinBlocking()
+
+ val useCase: SessionUseCases.GoBackUseCase = mock()
+
+ val feature = SessionFeature(store, useCase, mock())
+
+ assertTrue(feature.onBackPressed())
+ verify(useCase).invoke("A")
+ }
+ }
+
+ @Test
+ fun `stop releases engine view`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("D", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view, tabId = "D")
+ verify(view, never()).render(any())
+ feature.start()
+
+ store.waitUntilIdle()
+
+ verify(view).render(engineSession)
+
+ feature.stop()
+ verify(view).release()
+ }
+
+ @Test
+ fun `presenter observes crash state and does not create new engine session immediately`() {
+ val middleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+ val store = prepareStore(middleware)
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("A", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view, tabId = "A")
+ verify(view, never()).render(any())
+ feature.start()
+
+ store.dispatch(CrashAction.SessionCrashedAction("A")).joinBlocking()
+ store.waitUntilIdle()
+ verify(view, atLeastOnce()).release()
+ middleware.assertNotDispatched(EngineAction.CreateEngineSessionAction::class)
+ }
+
+ @Test
+ fun `last access is updated when session is rendered`() {
+ val store = prepareStore()
+
+ val actualView: View = mock()
+ val view: EngineView = mock()
+ doReturn(actualView).`when`(view).asView()
+
+ val engineSession: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction("B", engineSession)).joinBlocking()
+
+ val feature = SessionFeature(store, mock(), view)
+ verify(view, never()).render(any())
+
+ assertEquals(0L, store.state.findTab("B")?.lastAccess)
+ feature.start()
+ store.waitUntilIdle()
+
+ assertNotEquals(0L, store.state.findTab("B")?.lastAccess)
+ verify(view).render(engineSession)
+ }
+
+ private fun prepareStore(
+ middleware: CaptureActionsMiddleware<BrowserState, BrowserAction>? = null,
+ ): BrowserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://getpocket.com", id = "B"),
+ createTab("https://www.firefox.com", id = "C"),
+ ),
+ customTabs = listOf(
+ createCustomTab("https://hubs.mozilla.com/", id = "D"),
+ ),
+ selectedTabId = "B",
+ ),
+ middleware = (if (middleware != null) listOf(middleware) else emptyList()) + EngineMiddleware.create(
+ engine = mock(),
+ scope = scope,
+ ),
+ )
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionUseCasesTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionUseCasesTest.kt
new file mode 100644
index 0000000000..4461577d25
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SessionUseCasesTest.kt
@@ -0,0 +1,519 @@
+/* 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.session
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.CrashAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.engine.EngineMiddleware
+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.TabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SessionUseCasesTest {
+ private lateinit var middleware: CaptureActionsMiddleware<BrowserState, BrowserAction>
+ private lateinit var store: BrowserStore
+ private lateinit var useCases: SessionUseCases
+ private lateinit var engineSession: EngineSession
+ private lateinit var childEngineSession: EngineSession
+
+ @Before
+ fun setUp() {
+ engineSession = mock()
+ childEngineSession = mock()
+ middleware = CaptureActionsMiddleware()
+ store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "mozilla",
+ engineSession = engineSession,
+ ),
+ createTab(
+ url = "https://bugzilla.com",
+ id = "bugzilla",
+ engineSession = childEngineSession,
+ parentId = "mozilla",
+ ),
+ ),
+ selectedTabId = "mozilla",
+ ),
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mock()),
+ )
+ useCases = SessionUseCases(store)
+ }
+
+ @Test
+ fun loadUrlWithEngineSession() {
+ useCases.loadUrl("https://getpocket.com")
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.LoadUrlAction::class)
+ verify(engineSession).loadUrl(url = "https://getpocket.com")
+ middleware.assertLastAction(EngineAction.OptimizedLoadUrlTriggeredAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://getpocket.com", action.url)
+ }
+ assertEquals("https://getpocket.com", store.state.selectedTab?.content?.url)
+
+ useCases.loadUrl("https://www.mozilla.org", LoadUrlFlags.select(LoadUrlFlags.EXTERNAL))
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.LoadUrlAction::class)
+ verify(engineSession).loadUrl(
+ url = "https://www.mozilla.org",
+ flags = LoadUrlFlags.select(LoadUrlFlags.EXTERNAL),
+ )
+ middleware.assertLastAction(EngineAction.OptimizedLoadUrlTriggeredAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://www.mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.select(LoadUrlFlags.EXTERNAL), action.flags)
+ }
+ assertEquals("https://www.mozilla.org", store.state.selectedTab?.content?.url)
+
+ useCases.loadUrl("https://firefox.com", store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.LoadUrlAction::class)
+ verify(engineSession).loadUrl(url = "https://firefox.com")
+ middleware.assertLastAction(EngineAction.OptimizedLoadUrlTriggeredAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://firefox.com", action.url)
+ }
+ assertEquals("https://firefox.com", store.state.selectedTab?.content?.url)
+
+ useCases.loadUrl.invoke(
+ "https://developer.mozilla.org",
+ store.state.selectedTabId,
+ LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY),
+ )
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.LoadUrlAction::class)
+ verify(engineSession).loadUrl(
+ url = "https://developer.mozilla.org",
+ flags = LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY),
+ )
+ middleware.assertLastAction(EngineAction.OptimizedLoadUrlTriggeredAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://developer.mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY), action.flags)
+ }
+
+ useCases.loadUrl.invoke(
+ "https://www.mozilla.org/en-CA/firefox/browsers/mobile/",
+ "bugzilla",
+ )
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.LoadUrlAction::class)
+ verify(childEngineSession).loadUrl(
+ url = "https://www.mozilla.org/en-CA/firefox/browsers/mobile/",
+ parent = engineSession,
+ )
+ middleware.assertLastAction(EngineAction.OptimizedLoadUrlTriggeredAction::class) { action ->
+ assertEquals("bugzilla", action.tabId)
+ assertEquals("https://www.mozilla.org/en-CA/firefox/browsers/mobile/", action.url)
+ }
+ }
+
+ @Test
+ fun loadUrlWithoutEngineSession() {
+ store.dispatch(EngineAction.UnlinkEngineSessionAction("mozilla")).joinBlocking()
+
+ useCases.loadUrl("https://getpocket.com")
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://getpocket.com", action.url)
+ }
+
+ useCases.loadUrl("https://www.mozilla.org", LoadUrlFlags.select(LoadUrlFlags.EXTERNAL))
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://www.mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.select(LoadUrlFlags.EXTERNAL), action.flags)
+ }
+
+ useCases.loadUrl("https://firefox.com", store.state.selectedTabId)
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://firefox.com", action.url)
+ }
+
+ useCases.loadUrl.invoke(
+ "https://developer.mozilla.org",
+ store.state.selectedTabId,
+ LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY),
+ )
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("https://developer.mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY), action.flags)
+ }
+ }
+
+ @Test
+ fun loadData() {
+ useCases.loadData("<html><body></body></html>", "text/html")
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.LoadDataAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("<html><body></body></html>", action.data)
+ assertEquals("text/html", action.mimeType)
+ assertEquals("UTF-8", action.encoding)
+ }
+
+ useCases.loadData(
+ "Should load in WebView",
+ "text/plain",
+ tabId = store.state.selectedTabId,
+ )
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.LoadDataAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("Should load in WebView", action.data)
+ assertEquals("text/plain", action.mimeType)
+ assertEquals("UTF-8", action.encoding)
+ }
+
+ useCases.loadData(
+ "Should also load in WebView",
+ "text/plain",
+ "base64",
+ store.state.selectedTabId,
+ )
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.LoadDataAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals("Should also load in WebView", action.data)
+ assertEquals("text/plain", action.mimeType)
+ assertEquals("base64", action.encoding)
+ }
+ }
+
+ @Test
+ fun reload() {
+ useCases.reload()
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.ReloadAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ }
+
+ useCases.reload(store.state.selectedTabId, LoadUrlFlags.select(LoadUrlFlags.EXTERNAL))
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.ReloadAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals(LoadUrlFlags.select(LoadUrlFlags.EXTERNAL), action.flags)
+ }
+ }
+
+ @Test
+ fun reloadBypassCache() {
+ val flags = LoadUrlFlags.select(LoadUrlFlags.BYPASS_CACHE)
+ useCases.reload(store.state.selectedTabId, flags = flags)
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(EngineAction.ReloadAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals(flags, action.flags)
+ }
+ }
+
+ @Test
+ fun stopLoading() = runTest {
+ useCases.stopLoading()
+ store.waitUntilIdle()
+ verify(engineSession).stopLoading()
+
+ useCases.stopLoading(store.state.selectedTabId)
+ store.waitUntilIdle()
+ verify(engineSession, times(2)).stopLoading()
+ }
+
+ @Test
+ fun goBack() {
+ useCases.goBack(null)
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.GoBackAction::class)
+
+ useCases.goBack(store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoBackAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertTrue(action.userInteraction)
+ }
+ middleware.reset()
+
+ useCases.goBack(userInteraction = false)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoBackAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertFalse(action.userInteraction)
+ }
+ }
+
+ @Test
+ fun goForward() {
+ useCases.goForward(null)
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.GoForwardAction::class)
+
+ useCases.goForward(store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoForwardAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertTrue(action.userInteraction)
+ }
+ middleware.reset()
+
+ useCases.goForward(userInteraction = false)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoForwardAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertFalse(action.userInteraction)
+ }
+ }
+
+ @Test
+ fun goToHistoryIndex() {
+ useCases.goToHistoryIndex(tabId = null, index = 0)
+ store.waitUntilIdle()
+ middleware.assertNotDispatched(EngineAction.GoToHistoryIndexAction::class)
+
+ useCases.goToHistoryIndex(tabId = "test", index = 5)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoToHistoryIndexAction::class) { action ->
+ assertEquals("test", action.tabId)
+ assertEquals(5, action.index)
+ }
+
+ useCases.goToHistoryIndex(index = 10)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.GoToHistoryIndexAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertEquals(10, action.index)
+ }
+ }
+
+ @Test
+ fun requestDesktopSite() {
+ useCases.requestDesktopSite(true, store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ToggleDesktopModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertTrue(action.enable)
+ }
+
+ useCases.requestDesktopSite(false)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ToggleDesktopModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertFalse(action.enable)
+ }
+ useCases.requestDesktopSite(true, store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ToggleDesktopModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertTrue(action.enable)
+ }
+ useCases.requestDesktopSite(false, store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ToggleDesktopModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ assertFalse(action.enable)
+ }
+ }
+
+ @Test
+ fun exitFullscreen() {
+ useCases.exitFullscreen(store.state.selectedTabId)
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ExitFullScreenModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ }
+ middleware.reset()
+
+ useCases.exitFullscreen()
+ store.waitUntilIdle()
+ middleware.assertLastAction(EngineAction.ExitFullScreenModeAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ }
+ }
+
+ @Test
+ fun `LoadUrlUseCase will invoke onNoSession lambda if no selected session exists`() {
+ var createdTab: TabSessionState? = null
+ var tabCreatedForUrl: String? = null
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+
+ val loadUseCase = SessionUseCases.DefaultLoadUrlUseCase(store) { url ->
+ tabCreatedForUrl = url
+ createTab(url).also { createdTab = it }
+ }
+
+ loadUseCase("https://www.example.com")
+ store.waitUntilIdle()
+
+ assertEquals("https://www.example.com", tabCreatedForUrl)
+ assertNotNull(createdTab)
+
+ middleware.assertLastAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals(createdTab!!.id, action.tabId)
+ assertEquals(tabCreatedForUrl, action.url)
+ }
+ }
+
+ @Test
+ fun `LoadDataUseCase will invoke onNoSession lambda if no selected session exists`() {
+ var createdTab: TabSessionState? = null
+ var tabCreatedForUrl: String? = null
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+ store.waitUntilIdle()
+
+ val loadUseCase = SessionUseCases.LoadDataUseCase(store) { url ->
+ tabCreatedForUrl = url
+ createTab(url).also { createdTab = it }
+ }
+
+ loadUseCase("Hello", mimeType = "text/plain", encoding = "UTF-8")
+ store.waitUntilIdle()
+
+ assertEquals("about:blank", tabCreatedForUrl)
+ assertNotNull(createdTab)
+
+ middleware.assertLastAction(EngineAction.LoadDataAction::class) { action ->
+ assertEquals(createdTab!!.id, action.tabId)
+ assertEquals("Hello", action.data)
+ assertEquals("text/plain", action.mimeType)
+ assertEquals("UTF-8", action.encoding)
+ }
+ }
+
+ @Test
+ fun `CrashRecoveryUseCase will restore specified session`() {
+ useCases.crashRecovery.invoke(listOf("mozilla"))
+ store.waitUntilIdle()
+
+ middleware.assertLastAction(CrashAction.RestoreCrashedSessionAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ }
+ }
+
+ @Test
+ fun `CrashRecoveryUseCase will restore list of crashed sessions`() {
+ val store = spy(
+ BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(url = "https://wwww.mozilla.org", id = "tab1", crashed = true),
+ ),
+ customTabs = listOf(
+ createCustomTab(
+ "https://wwww.mozilla.org",
+ id = "customTab1",
+ crashed = true,
+ ),
+ ),
+ ),
+ ),
+ )
+ val useCases = SessionUseCases(store)
+
+ useCases.crashRecovery.invoke()
+ store.waitUntilIdle()
+
+ middleware.assertFirstAction(CrashAction.RestoreCrashedSessionAction::class) { action ->
+ assertEquals("tab1", action.tabId)
+ }
+
+ middleware.assertLastAction(CrashAction.RestoreCrashedSessionAction::class) { action ->
+ assertEquals("customTab1", action.tabId)
+ }
+ }
+
+ @Test
+ fun `PurgeHistoryUseCase dispatches PurgeHistory action`() {
+ useCases.purgeHistory()
+ store.waitUntilIdle()
+
+ middleware.findFirstAction(EngineAction.PurgeHistoryAction::class)
+ }
+
+ @Test
+ fun `UpdateLastAccessUseCase sets timestamp`() {
+ val tab = createTab("https://firefox.com")
+ val otherTab = createTab("https://example.com")
+ val customTab = createCustomTab("https://getpocket.com")
+ store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab, otherTab),
+ customTabs = listOf(customTab),
+ ),
+ )
+ useCases = SessionUseCases(store)
+
+ // Make sure use case doesn't crash for custom tab and non-existent tab
+ useCases.updateLastAccess(customTab.id)
+ store.waitUntilIdle()
+ assertEquals(0L, store.state.findTab(tab.id)?.lastAccess)
+
+ // Update last access for a specific tab with default value
+ useCases.updateLastAccess(tab.id)
+ store.waitUntilIdle()
+ assertNotEquals(0L, store.state.findTab(tab.id)?.lastAccess)
+
+ // Update last access for a specific tab with specific value
+ useCases.updateLastAccess(tab.id, 123L)
+ store.waitUntilIdle()
+ assertEquals(123L, store.state.findTab(tab.id)?.lastAccess)
+
+ // Update last access for currently selected tab
+ store.dispatch(TabListAction.SelectTabAction(otherTab.id)).joinBlocking()
+ assertEquals(0L, store.state.findTab(otherTab.id)?.lastAccess)
+ useCases.updateLastAccess()
+ store.waitUntilIdle()
+ assertNotEquals(0L, store.state.findTab(otherTab.id)?.lastAccess)
+
+ // Update last access for currently selected tab with specific value
+ store.dispatch(TabListAction.SelectTabAction(otherTab.id)).joinBlocking()
+ useCases.updateLastAccess(lastAccess = 345L)
+ store.waitUntilIdle()
+ assertEquals(345L, store.state.findTab(otherTab.id)?.lastAccess)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SettingsUseCasesTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SettingsUseCasesTest.kt
new file mode 100644
index 0000000000..2a89bdfe7e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SettingsUseCasesTest.kt
@@ -0,0 +1,73 @@
+/* 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.session
+
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
+import mozilla.components.concept.engine.Settings
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+
+class SettingsUseCasesTest {
+ @Test
+ fun updateTrackingProtection() {
+ val engineSessionA: EngineSession = mock()
+ val engineSessionB: EngineSession = mock()
+
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.mozilla.org", id = "B"),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ store.dispatch(
+ EngineAction.LinkEngineSessionAction(
+ tabId = "A",
+ engineSession = engineSessionA,
+ ),
+ ).joinBlocking()
+
+ store.dispatch(
+ EngineAction.LinkEngineSessionAction(
+ tabId = "B",
+ engineSession = engineSessionB,
+ ),
+ ).joinBlocking()
+
+ val engine: Engine = mock()
+ val settings: Settings = mock()
+ doReturn(settings).`when`(engine).settings
+
+ val useCases = SettingsUseCases(engine, store)
+
+ useCases.updateTrackingProtection(TrackingProtectionPolicy.none())
+ verify(settings).trackingProtectionPolicy = TrackingProtectionPolicy.none()
+ verify(engineSessionA).updateTrackingProtection(TrackingProtectionPolicy.none())
+ verify(engineSessionB).updateTrackingProtection(TrackingProtectionPolicy.none())
+ verify(engine).clearSpeculativeSession()
+
+ reset(engine)
+ doReturn(settings).`when`(engine).settings
+
+ useCases.updateTrackingProtection(TrackingProtectionPolicy.strict())
+ verify(settings).trackingProtectionPolicy = TrackingProtectionPolicy.strict()
+ verify(engineSessionA).updateTrackingProtection(TrackingProtectionPolicy.strict())
+ verify(engineSessionB).updateTrackingProtection(TrackingProtectionPolicy.strict())
+ verify(engine).clearSpeculativeSession()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt
new file mode 100644
index 0000000000..2205de348f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt
@@ -0,0 +1,130 @@
+/* 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.session
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.widget.FrameLayout
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.engine.InputResultDetail
+import mozilla.components.concept.engine.selection.SelectionActionDelegate
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class SwipeRefreshFeatureTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var store: BrowserStore
+ private lateinit var refreshFeature: SwipeRefreshFeature
+ private val mockLayout = mock<SwipeRefreshLayout>()
+ private val useCase = mock<SessionUseCases.ReloadUrlUseCase>()
+
+ @Before
+ fun setup() {
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "A"),
+ createTab("https://www.firefox.com", id = "B"),
+ ),
+ selectedTabId = "B",
+ ),
+ )
+
+ refreshFeature = SwipeRefreshFeature(store, useCase, mockLayout)
+ }
+
+ @Test
+ fun `sets the onRefreshListener and onChildScrollUpCallback`() {
+ verify(mockLayout).setOnRefreshListener(refreshFeature)
+ verify(mockLayout).setOnChildScrollUpCallback(refreshFeature)
+ }
+
+ @Test
+ fun `gesture should only work if the content can be overscrolled`() {
+ val engineView: DummyEngineView = mock()
+ val inputResultDetail: InputResultDetail = mock()
+ doReturn(inputResultDetail).`when`(engineView).getInputResultDetail()
+
+ doReturn(true).`when`(inputResultDetail).canOverscrollTop()
+ assertFalse(refreshFeature.canChildScrollUp(mockLayout, engineView))
+
+ doReturn(false).`when`(inputResultDetail).canOverscrollTop()
+ assertTrue(refreshFeature.canChildScrollUp(mockLayout, engineView))
+ }
+
+ @Test
+ fun `onRefresh should refresh the active session`() {
+ refreshFeature.start()
+ refreshFeature.onRefresh()
+
+ verify(useCase).invoke("B")
+ }
+
+ @Test
+ fun `feature MUST reset refreshCanceled after is used`() {
+ refreshFeature.start()
+
+ val selectedTab = store.state.findCustomTabOrSelectedTab()!!
+
+ store.dispatch(ContentAction.UpdateRefreshCanceledStateAction(selectedTab.id, true)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertFalse(selectedTab.content.refreshCanceled)
+ }
+
+ @Test
+ fun `feature clears the swipeRefreshLayout#isRefreshing when tab fishes loading or a refreshCanceled`() {
+ refreshFeature.start()
+ store.waitUntilIdle()
+
+ val selectedTab = store.state.findCustomTabOrSelectedTab()!!
+
+ // Ignoring the first event from the initial state.
+ reset(mockLayout)
+
+ store.dispatch(ContentAction.UpdateRefreshCanceledStateAction(selectedTab.id, true)).joinBlocking()
+ store.waitUntilIdle()
+
+ verify(mockLayout, times(2)).isRefreshing = false
+
+ // To trigger to an event we have to change loading from its previous value (false to true).
+ // As if we dispatch with loading = false, none event will be trigger.
+ store.dispatch(ContentAction.UpdateLoadingStateAction(selectedTab.id, true)).joinBlocking()
+ store.dispatch(ContentAction.UpdateLoadingStateAction(selectedTab.id, false)).joinBlocking()
+
+ verify(mockLayout, times(3)).isRefreshing = false
+ }
+
+ private open class DummyEngineView(context: Context) : FrameLayout(context), EngineView {
+ override fun setVerticalClipping(clippingHeight: Int) {}
+ override fun setDynamicToolbarMaxHeight(height: Int) {}
+ override fun setActivityContext(context: Context?) {}
+ override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit
+ override fun clearSelection() {}
+ override fun render(session: EngineSession) {}
+ override fun release() {}
+ override var selectionActionDelegate: SelectionActionDelegate? = null
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt
new file mode 100644
index 0000000000..4e72afed45
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/TrackingProtectionUseCasesTest.kt
@@ -0,0 +1,266 @@
+/* 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.session
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.selector.findCustomTab
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.EngineState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.TrackingProtectionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.content.blocking.TrackerLog
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionException
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class TrackingProtectionUseCasesTest {
+ private lateinit var exceptionStore: TrackingProtectionExceptionStorage
+ private lateinit var engine: Engine
+ private lateinit var store: BrowserStore
+ private lateinit var engineSession: EngineSession
+ private lateinit var useCases: TrackingProtectionUseCases
+
+ @Before
+ fun setUp() {
+ exceptionStore = mock()
+
+ engine = mock()
+ whenever(engine.trackingProtectionExceptionStore).thenReturn(exceptionStore)
+
+ engineSession = mock()
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "A",
+ content = ContentState("https://www.mozilla.org"),
+ engineState = EngineState(engineSession),
+ ),
+ ),
+ selectedTabId = "A",
+ ),
+ )
+
+ useCases = TrackingProtectionUseCases(store, engine)
+ }
+
+ @Test
+ fun `fetch trackers logged successfully`() {
+ var trackersLog: List<TrackerLog>? = null
+ var onSuccessCalled = false
+ var onErrorCalled = false
+ val onSuccess: (List<TrackerLog>) -> Unit = {
+ trackersLog = it
+ onSuccessCalled = true
+ }
+
+ whenever(engine.getTrackersLog(any(), any(), any())).then {
+ onSuccess(emptyList())
+ }
+
+ val useCases = TrackingProtectionUseCases(store, engine)
+
+ useCases.fetchTrackingLogs(
+ "A",
+ onSuccess = {
+ trackersLog = it
+ onSuccessCalled = true
+ },
+ onError = {
+ onErrorCalled = true
+ },
+ )
+
+ assertNotNull(trackersLog)
+ assertTrue(onSuccessCalled)
+ assertFalse(onErrorCalled)
+ }
+
+ @Test
+ fun `calling fetchTrackingLogs with a null engine session will call onError`() {
+ var trackersLog: List<TrackerLog>? = null
+ var onSuccessCalled = false
+ var onErrorCalled = false
+ val onSuccess: (List<TrackerLog>) -> Unit = {
+ trackersLog = it
+ onSuccessCalled = true
+ }
+
+ store.dispatch(
+ EngineAction.UnlinkEngineSessionAction("A"),
+ ).joinBlocking()
+
+ whenever(engine.getTrackersLog(any(), any(), any())).then {
+ onSuccess(emptyList())
+ }
+
+ useCases.fetchTrackingLogs(
+ "A",
+ onSuccess = {
+ trackersLog = it
+ onSuccessCalled = true
+ },
+ onError = {
+ onErrorCalled = true
+ },
+ )
+
+ assertNull(trackersLog)
+ assertTrue(onErrorCalled)
+ assertFalse(onSuccessCalled)
+ }
+
+ @Test
+ fun `add exception`() {
+ useCases.addException("A")
+
+ verify(exceptionStore).add(engineSession)
+ }
+
+ @Test
+ fun `add exception with a null engine session will not call the store`() {
+ store.dispatch(
+ EngineAction.UnlinkEngineSessionAction("A"),
+ ).joinBlocking()
+
+ useCases.addException("A")
+
+ verify(exceptionStore, never()).add(any(), eq(false))
+ }
+
+ @Test
+ fun `GIVEN a persistent in private mode exception WHEN adding THEN add the exception and indicate that it is persistent`() {
+ val tabId = "A"
+ useCases.addException(tabId, persistInPrivateMode = true)
+
+ verify(exceptionStore).add(engineSession, true)
+ }
+
+ @Test
+ fun `remove a session exception`() {
+ useCases.removeException("A")
+
+ verify(exceptionStore).remove(engineSession)
+ }
+
+ @Test
+ fun `remove a tracking protection exception`() {
+ val tab1 = createTab("https://www.mozilla.org")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true))
+
+ val tab2 = createTab("https://wiki.mozilla.org/")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true))
+
+ val tab3 = createTab("https://www.mozilla.org/en-CA/")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true))
+
+ val customTab = createCustomTab("https://www.mozilla.org/en-CA/")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true))
+
+ val exception = object : TrackingProtectionException {
+ override val url: String = tab1.content.url
+ }
+
+ store.dispatch(TabListAction.AddTabAction(tab1)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab2)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab3)).joinBlocking()
+ store.dispatch(CustomTabListAction.AddCustomTabAction(customTab)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(store.state.findTab(tab1.id)!!.trackingProtection.ignoredOnTrackingProtection)
+ assertTrue(store.state.findTab(tab2.id)!!.trackingProtection.ignoredOnTrackingProtection)
+ assertTrue(store.state.findTab(tab3.id)!!.trackingProtection.ignoredOnTrackingProtection)
+ assertTrue(store.state.findCustomTab(customTab.id)!!.trackingProtection.ignoredOnTrackingProtection)
+
+ useCases.removeException(exception)
+
+ verify(exceptionStore).remove(exception)
+
+ store.waitUntilIdle()
+
+ assertFalse(store.state.findTab(tab1.id)!!.trackingProtection.ignoredOnTrackingProtection)
+
+ // Different domain from tab1 MUST not be affected
+ assertTrue(store.state.findTab(tab2.id)!!.trackingProtection.ignoredOnTrackingProtection)
+
+ // Another tabs with the same domain as tab1 MUST be updated
+ assertFalse(store.state.findTab(tab3.id)!!.trackingProtection.ignoredOnTrackingProtection)
+ assertFalse(store.state.findCustomTab(customTab.id)!!.trackingProtection.ignoredOnTrackingProtection)
+ }
+
+ @Test
+ fun `remove exception with a null engine session will not call the store`() {
+ store.dispatch(
+ EngineAction.UnlinkEngineSessionAction("A"),
+ ).joinBlocking()
+
+ useCases.removeException("A")
+
+ verify(exceptionStore, never()).remove(any<EngineSession>())
+ }
+
+ @Test
+ fun `removeAll exceptions`() {
+ useCases.removeAllExceptions()
+
+ verify(exceptionStore).removeAll(any(), any())
+ }
+
+ @Test
+ fun `contains exception`() {
+ val callback: (Boolean) -> Unit = {}
+ useCases.containsException("A", callback)
+
+ verify(exceptionStore).contains(engineSession, callback)
+ }
+
+ @Test
+ fun `contains exception with a null engine session will not call the store`() {
+ var contains = true
+
+ store.dispatch(
+ EngineAction.UnlinkEngineSessionAction("A"),
+ ).joinBlocking()
+
+ useCases.containsException("A") {
+ contains = it
+ }
+
+ assertFalse(contains)
+ verify(exceptionStore, never()).contains(any(), any())
+ }
+
+ @Test
+ fun `fetch exceptions`() {
+ useCases.fetchExceptions {}
+ verify(exceptionStore).fetchAll(any())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/LastAccessMiddlewareTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/LastAccessMiddlewareTest.kt
new file mode 100644
index 0000000000..9961a8b084
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/LastAccessMiddlewareTest.kt
@@ -0,0 +1,319 @@
+/* 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.session.middleware
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.TabListAction
+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.createTab
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.lib.state.MiddlewareContext
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LastAccessMiddlewareTest {
+ lateinit var store: BrowserStore
+ lateinit var context: MiddlewareContext<BrowserState, BrowserAction>
+
+ @Before
+ fun setup() {
+ store = BrowserStore()
+ context = mock()
+
+ whenever(context.store).thenReturn(store)
+ whenever(context.state).thenReturn(store.state)
+ }
+
+ @Test
+ fun `UpdateLastAction is dispatched when tab is selected`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+
+ store.dispatch(TabListAction.SelectTabAction("456")).joinBlocking()
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertNotEquals(0L, store.state.tabs[1].lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is dispatched when a new tab is selected`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+
+ val newTab = createTab("https://firefox.com", id = "456")
+ store.dispatch(TabListAction.AddTabAction(newTab, select = true)).joinBlocking()
+
+ assertEquals("456", store.state.selectedTabId)
+ assertNotEquals(0L, store.state.selectedTab?.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is not dispatched when a new tab is not selected`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+
+ val newTab = createTab("https://firefox.com", id = "456")
+ store.dispatch(TabListAction.AddTabAction(newTab, select = false)).joinBlocking()
+
+ assertEquals("123", store.state.selectedTabId)
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is dispatched when URL of selected tab changes`() {
+ val tab = createTab("https://firefox.com", id = "123")
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(tab),
+ selectedTabId = tab.id,
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org")).joinBlocking()
+ assertNotEquals(0L, store.state.selectedTab?.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is not dispatched when URL of non-selected tab changes`() {
+ val tab = createTab("https://firefox.com", id = "123")
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(tab),
+ selectedTabId = tab.id,
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+
+ val newTab = createTab("https://mozilla.org", id = "456")
+ store.dispatch(TabListAction.AddTabAction(newTab)).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(newTab.id, "https://mozilla.org")).joinBlocking()
+ assertEquals(0L, store.state.selectedTab?.lastAccess)
+ assertEquals(0L, store.state.findTab(newTab.id)?.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is dispatched when tab is selected during removal of single tab`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+
+ store.dispatch(TabListAction.RemoveTabAction("123")).joinBlocking()
+
+ val selectedTab = store.state.findTab("456")
+ assertNotNull(selectedTab)
+ assertEquals(selectedTab!!.id, store.state.selectedTabId)
+ assertNotEquals(0L, selectedTab.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is not dispatched when no new tab is selected during removal of single tab`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+
+ store.dispatch(TabListAction.RemoveTabAction("456")).joinBlocking()
+ val selectedTab = store.state.findTab("123")
+ assertNotNull(selectedTab)
+ assertEquals(selectedTab!!.id, store.state.selectedTabId)
+ assertEquals(0L, selectedTab.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is dispatched when tab is selected during removal of multiple tab`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456"),
+ createTab("https://getpocket.com", id = "789"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+ assertEquals(0L, store.state.tabs[2].lastAccess)
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf("123", "456"))).joinBlocking()
+
+ val selectedTab = store.state.findTab("789")
+ assertNotNull(selectedTab)
+ assertEquals(selectedTab!!.id, store.state.selectedTabId)
+ assertNotEquals(0L, selectedTab.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is not dispatched when no new tab is selected during removal of multiple tab`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456"),
+ createTab("https://getpocket.com", id = "789"),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+ assertEquals(0L, store.state.tabs[2].lastAccess)
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf("456", "789"))).joinBlocking()
+ val selectedTab = store.state.findTab("123")
+ assertEquals(selectedTab!!.id, store.state.selectedTabId)
+ assertEquals(0L, selectedTab.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is not dispatched when no new tab is selected during removal of all private tab`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://mozilla.org", id = "123"),
+ createTab("https://firefox.com", id = "456", private = true),
+ createTab("https://getpocket.com", id = "789", private = true),
+ ),
+ selectedTabId = "123",
+ ),
+ middleware = listOf(LastAccessMiddleware()),
+ )
+
+ assertEquals(0L, store.state.tabs[0].lastAccess)
+ assertEquals(0L, store.state.tabs[1].lastAccess)
+ assertEquals(0L, store.state.tabs[2].lastAccess)
+
+ store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
+
+ val selectedTab = store.state.findTab("123")
+ assertNotNull(selectedTab)
+ assertEquals(selectedTab!!.id, store.state.selectedTabId)
+ assertEquals(0L, selectedTab.lastAccess)
+ }
+
+ @Test
+ fun `UpdateLastAction is invoked for selected tab from RestoreAction`() {
+ val recentTime = System.currentTimeMillis()
+ val lastAccess = 3735928559
+ val store = BrowserStore(
+ middleware = listOf(LastAccessMiddleware()),
+ )
+ val recoverableTabs = listOf(
+ RecoverableTab(
+ engineSessionState = null,
+ state = TabState(url = "https://firefox.com", id = "1", lastAccess = lastAccess),
+ ),
+ RecoverableTab(
+ engineSessionState = null,
+ state = TabState(url = "https://mozilla.org", id = "2", lastAccess = lastAccess),
+ ),
+ )
+
+ store.dispatch(
+ TabListAction.RestoreAction(
+ recoverableTabs,
+ "2",
+ TabListAction.RestoreAction.RestoreLocation.BEGINNING,
+ ),
+ ).joinBlocking()
+
+ assertTrue(store.state.tabs.size == 2)
+
+ val restoredTab1 = store.state.findTab("1")
+ val restoredTab2 = store.state.findTab("2")
+ assertNotNull(restoredTab1)
+ assertNotNull(restoredTab2)
+
+ assertNotEquals(restoredTab2!!.lastAccess, lastAccess)
+ assertTrue(restoredTab2.lastAccess > lastAccess)
+ assertTrue(restoredTab2.lastAccess > recentTime)
+
+ assertEquals(restoredTab1!!.lastAccess, lastAccess)
+ assertFalse(restoredTab1.lastAccess > lastAccess)
+ assertFalse(restoredTab1.lastAccess > recentTime)
+ }
+
+ @Test
+ fun `sanity check - next is always invoked in the middleware`() {
+ var nextInvoked = false
+ val middleware = LastAccessMiddleware()
+
+ middleware.invoke(context, { nextInvoked = true }, TabListAction.RemoveTabAction("123"))
+
+ assertTrue(nextInvoked)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/undo/UndoMiddlewareTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/undo/UndoMiddlewareTest.kt
new file mode 100644
index 0000000000..59c7ff6141
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/middleware/undo/UndoMiddlewareTest.kt
@@ -0,0 +1,294 @@
+/* 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.session.middleware.undo
+
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.withContext
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.UndoAction
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class UndoMiddlewareTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `Undo scenario - Removing single tab`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ ),
+ selectedTabId = "mozilla",
+ ),
+ )
+
+ assertEquals(2, store.state.tabs.size)
+ assertEquals(2, store.state.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveTabAction(tabId = "mozilla"),
+ ).joinBlocking()
+
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertEquals(2, store.state.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.selectedTab!!.content.url)
+ }
+
+ @Test
+ fun `Undo scenario - Removing list of tabs`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://firefox.com", id = "firefox"),
+ ),
+ selectedTabId = "mozilla",
+ ),
+ )
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveTabsAction(listOf("mozilla", "pocket")),
+ ).joinBlocking()
+
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("https://firefox.com", store.state.selectedTab!!.content.url)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.selectedTab!!.content.url)
+ }
+
+ @Test
+ fun `Undo scenario - Removing all normal tabs`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://reddit.com/r/firefox", id = "reddit", private = true),
+ ),
+ selectedTabId = "pocket",
+ ),
+ )
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveAllNormalTabsAction,
+ ).joinBlocking()
+
+ assertEquals(1, store.state.tabs.size)
+ assertNull(store.state.selectedTab)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+ }
+
+ @Test
+ fun `Undo scenario - Removing all tabs`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://reddit.com/r/firefox", id = "reddit", private = true),
+ ),
+ selectedTabId = "pocket",
+ ),
+ )
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveAllTabsAction(),
+ ).joinBlocking()
+
+ assertEquals(0, store.state.tabs.size)
+ assertNull(store.state.selectedTab)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+ }
+
+ @Test
+ fun `Undo scenario - Removing all tabs non-recoverable`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://reddit.com/r/firefox", id = "reddit", private = true),
+ ),
+ selectedTabId = "pocket",
+ ),
+ )
+
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveAllTabsAction(false),
+ ).joinBlocking()
+
+ assertEquals(0, store.state.tabs.size)
+ assertNull(store.state.selectedTab)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ store.waitUntilIdle()
+
+ assertEquals(0, store.state.tabs.size)
+ }
+
+ @Test
+ fun `Undo History in State is written`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://reddit.com/r/firefox", id = "reddit", private = true),
+ ),
+ selectedTabId = "pocket",
+ ),
+ )
+
+ assertNull(store.state.undoHistory.selectedTabId)
+ assertTrue(store.state.undoHistory.tabs.isEmpty())
+ assertEquals(3, store.state.tabs.size)
+
+ store.dispatch(
+ TabListAction.RemoveAllPrivateTabsAction,
+ ).joinBlocking()
+
+ assertNull(store.state.undoHistory.selectedTabId)
+ assertEquals(1, store.state.undoHistory.tabs.size)
+ assertEquals("https://reddit.com/r/firefox", store.state.undoHistory.tabs[0].state.url)
+ assertEquals(2, store.state.tabs.size)
+
+ store.dispatch(
+ TabListAction.RemoveAllNormalTabsAction,
+ ).joinBlocking()
+
+ assertEquals("pocket", store.state.undoHistory.selectedTabId)
+ assertEquals(2, store.state.undoHistory.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.undoHistory.tabs[0].state.url)
+ assertEquals("https://getpocket.com", store.state.undoHistory.tabs[1].state.url)
+ assertEquals(0, store.state.tabs.size)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertNull(store.state.undoHistory.selectedTabId)
+ assertTrue(store.state.undoHistory.tabs.isEmpty())
+ assertEquals(0, store.state.undoHistory.tabs.size)
+ assertEquals(2, store.state.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.tabs[0].content.url)
+ assertEquals("https://getpocket.com", store.state.tabs[1].content.url)
+ }
+
+ @Test
+ fun `Undo History gets cleared after time`() = runTestOnMain {
+ val store = BrowserStore(
+ middleware = listOf(
+ UndoMiddleware(clearAfterMillis = 60000, waitScope = coroutinesTestRule.scope),
+ ),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "mozilla"),
+ createTab("https://getpocket.com", id = "pocket"),
+ createTab("https://reddit.com/r/firefox", id = "reddit", private = true),
+ ),
+ selectedTabId = "pocket",
+ ),
+ )
+ assertEquals(3, store.state.tabs.size)
+ assertEquals("https://getpocket.com", store.state.selectedTab!!.content.url)
+
+ store.dispatch(
+ TabListAction.RemoveAllNormalTabsAction,
+ ).joinBlocking()
+
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("https://reddit.com/r/firefox", store.state.tabs[0].content.url)
+ assertEquals("pocket", store.state.undoHistory.selectedTabId)
+ assertEquals(2, store.state.undoHistory.tabs.size)
+ assertEquals("https://www.mozilla.org", store.state.undoHistory.tabs[0].state.url)
+ assertEquals("https://getpocket.com", store.state.undoHistory.tabs[1].state.url)
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ assertNull(store.state.undoHistory.selectedTabId)
+ assertTrue(store.state.undoHistory.tabs.isEmpty())
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("https://reddit.com/r/firefox", store.state.tabs[0].content.url)
+
+ restoreRecoverableTabs(dispatcher, store)
+
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("https://reddit.com/r/firefox", store.state.tabs[0].content.url)
+ }
+}
+
+private suspend fun restoreRecoverableTabs(dispatcher: TestDispatcher, store: BrowserStore) {
+ withContext(dispatcher) {
+ // We need to pause the test dispatcher here to avoid it dispatching immediately.
+ // Otherwise we deadlock the test here when we wait for the store to complete and
+ // at the same time the middleware dispatches a coroutine on the dispatcher which will
+ // also block on the store in SessionManager.restore().
+ store.dispatch(UndoAction.RestoreRecoverableTabs).joinBlocking()
+ }
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+}
diff --git a/mobile/android/android-components/components/feature/session/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/session/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/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/mobile/android/android-components/components/feature/session/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/session/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/session/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28