diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/feature/session | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-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')
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 |