summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/recentlyclosed
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/feature/recentlyclosed
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/recentlyclosed')
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/README.md19
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/build.gradle81
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/schemas/mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase/1.json52
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/androidTest/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageOnDeviceTest.kt103
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddleware.kt144
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorage.kt136
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabDao.kt43
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabEntity.kt52
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabsDatabase.kt36
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddlewareTest.kt383
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabDaoTest.kt136
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageTest.kt355
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/recentlyclosed/src/test/resources/robolectric.properties1
16 files changed, 1568 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/README.md b/mobile/android/android-components/components/feature/recentlyclosed/README.md
new file mode 100644
index 0000000000..893f648919
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Feature > RecentlyClosed
+
+Feature implementation for saving and restoring of recently closed tabs.
+
+## 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-recentlyclosed:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/build.gradle b/mobile/android/android-components/components/feature/recentlyclosed/build.gradle
new file mode 100644
index 0000000000..586afb1edc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/build.gradle
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'com.google.devtools.ksp'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas".toString())
+ arg("room.generateKotlin", "true")
+ }
+
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments += ["room.incremental": "true"]
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ packagingOptions {
+ exclude 'META-INF/proguard/androidx-annotations.pro'
+ }
+
+ sourceSets {
+ test.assets.srcDirs += files("$projectDir/schemas".toString())
+ }
+
+ namespace 'mozilla.components.feature.recentlyclosed'
+}
+
+dependencies {
+ implementation project(':concept-engine')
+
+ implementation project(':browser-state')
+ implementation project(':browser-session-storage')
+
+ implementation project(':support-ktx')
+ implementation project(':support-base')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation ComponentsDependencies.androidx_room_runtime
+ implementation ComponentsDependencies.androidx_lifecycle_livedata
+ ksp ComponentsDependencies.androidx_room_compiler
+
+ testImplementation project(':feature-session')
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.kotlin_coroutines
+ testImplementation ComponentsDependencies.testing_coroutines
+
+ androidTestImplementation project(':support-test-fakes')
+
+ androidTestImplementation ComponentsDependencies.androidx_test_core
+ androidTestImplementation ComponentsDependencies.androidx_test_runner
+}
+
+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/recentlyclosed/proguard-rules.pro b/mobile/android/android-components/components/feature/recentlyclosed/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/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/recentlyclosed/schemas/mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase/1.json b/mobile/android/android-components/components/feature/recentlyclosed/schemas/mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase/1.json
new file mode 100644
index 0000000000..c14a8fe68d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/schemas/mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase/1.json
@@ -0,0 +1,52 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "e7ff8844186c753ba34fbc5a6aabd320",
+ "entities": [
+ {
+ "tableName": "recently_closed_tabs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uuid"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e7ff8844186c753ba34fbc5a6aabd320')"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/androidTest/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageOnDeviceTest.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/androidTest/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageOnDeviceTest.kt
new file mode 100644
index 0000000000..4a6ee0923f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/androidTest/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageOnDeviceTest.kt
@@ -0,0 +1,103 @@
+/* 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.recentlyclosed
+
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.concept.base.crash.Breadcrumb
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.EngineSessionState
+import mozilla.components.concept.engine.EngineSessionStateStorage
+import mozilla.components.support.test.fakes.engine.FakeEngine
+import mozilla.components.support.test.fakes.engine.FakeEngineSessionState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RecentlyClosedTabsStorageOnDeviceTest {
+ private val engineState = FakeEngineSessionState("testId")
+ private val storage = RecentlyClosedTabsStorage(
+ context = ApplicationProvider.getApplicationContext(),
+ engine = FakeEngine(),
+ crashReporting = FakeCrashReporting(),
+ engineStateStorage = FakeEngineSessionStateStorage(),
+ )
+
+ @Test
+ fun testRowTooBigExceptionCaughtAndStorageCleared() = runBlocking {
+ val closedTab1 = RecoverableTab(
+ engineSessionState = engineState,
+ state = TabState(
+ id = "test",
+ title = "Pocket",
+ url = "test",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+ val closedTab2 = closedTab1.copy(
+ state = closedTab1.state.copy(
+ url = "test".repeat(1_000_000), // much more than 2MB of data. Just to be sure.
+ ),
+ )
+
+ // First check what happens if too large tabs are persisted and then asked for
+ storage.addTabsToCollectionWithMax(listOf(closedTab1, closedTab2), 2)
+ assertFalse((storage.engineStateStorage() as FakeEngineSessionStateStorage).data.isEmpty())
+ val corruptedTabsResult = storage.getTabs().first()
+ assertTrue(corruptedTabsResult.isEmpty())
+ assertTrue((storage.engineStateStorage() as FakeEngineSessionStateStorage).data.isEmpty())
+
+ // Then check that new data is persisted and queried successfully
+ val closedTab3 = RecoverableTab(
+ engineSessionState = engineState,
+ state = TabState(
+ id = "test2",
+ title = "Pocket2",
+ url = "test2",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+ storage.addTabState(closedTab3)
+ val recentlyClosedTabsResult = storage.getTabs().first()
+ assertEquals(listOf(closedTab3.state), recentlyClosedTabsResult)
+ assertEquals(1, (storage.engineStateStorage() as FakeEngineSessionStateStorage).data.size)
+ }
+}
+
+private class FakeCrashReporting : CrashReporting {
+ override fun submitCaughtException(throwable: Throwable): Job {
+ return MainScope().launch {}
+ }
+
+ override fun recordCrashBreadcrumb(breadcrumb: Breadcrumb) {}
+}
+
+private class FakeEngineSessionStateStorage : EngineSessionStateStorage {
+ val data: MutableMap<String, EngineSessionState?> = mutableMapOf()
+
+ override suspend fun write(uuid: String, state: EngineSessionState): Boolean {
+ data[uuid] = state
+ return true
+ }
+
+ override suspend fun read(uuid: String): EngineSessionState? {
+ return data[uuid]
+ }
+
+ override suspend fun delete(uuid: String) {
+ data.remove(uuid)
+ }
+
+ override suspend fun deleteAll() {
+ data.clear()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/recentlyclosed/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/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/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddleware.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddleware.kt
new file mode 100644
index 0000000000..6abfb19650
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddleware.kt
@@ -0,0 +1,144 @@
+/* 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.recentlyclosed
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.InitAction
+import mozilla.components.browser.state.action.RecentlyClosedAction
+import mozilla.components.browser.state.action.UndoAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.MiddlewareContext
+import mozilla.components.lib.state.Store
+
+/**
+ * [Middleware] implementation for handling [RecentlyClosedAction]s and syncing the closed tabs in
+ * [BrowserState.closedTabs] with the [RecentlyClosedTabsStorage].
+ */
+class RecentlyClosedMiddleware(
+ private val storage: Lazy<Storage>,
+ private val maxSavedTabs: Int,
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
+) : Middleware<BrowserState, BrowserAction> {
+
+ @Suppress("ComplexMethod")
+ override fun invoke(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ next: (BrowserAction) -> Unit,
+ action: BrowserAction,
+ ) {
+ when (action) {
+ is UndoAction.ClearRecoverableTabs -> {
+ if (action.tag == context.state.undoHistory.tag) {
+ // If the user has removed tabs and not invoked "undo" then let's save all non
+ // private tabs.
+ context.store.dispatch(
+ RecentlyClosedAction.AddClosedTabsAction(
+ context.state.undoHistory.tabs.filter { tab -> !tab.state.private },
+ ),
+ )
+ }
+ }
+ is UndoAction.AddRecoverableTabs -> {
+ if (context.state.undoHistory.tabs.isNotEmpty()) {
+ // If new tabs get added to the undo history and there were some previously
+ // then add them to the list of closed tabs now since they will never go through
+ // the clear call above.
+ context.store.dispatch(
+ RecentlyClosedAction.AddClosedTabsAction(
+ context.state.undoHistory.tabs.filter { tab -> !tab.state.private },
+ ),
+ )
+ }
+ }
+ is RecentlyClosedAction.AddClosedTabsAction -> {
+ addTabsToStorage(action.tabs)
+ }
+ is RecentlyClosedAction.RemoveAllClosedTabAction -> {
+ removeAllTabs()
+ }
+ is RecentlyClosedAction.RemoveClosedTabAction -> {
+ removeTab(action)
+ }
+ is InitAction -> {
+ initializeRecentlyClosed(context.store)
+ }
+ else -> {
+ // no-op
+ }
+ }
+
+ next(action)
+
+ pruneTabs(context.store)
+ }
+
+ private fun pruneTabs(store: Store<BrowserState, BrowserAction>) {
+ if (store.state.closedTabs.size > maxSavedTabs) {
+ store.dispatch(RecentlyClosedAction.PruneClosedTabsAction(maxSavedTabs))
+ }
+ }
+
+ private fun initializeRecentlyClosed(
+ store: Store<BrowserState, BrowserAction>,
+ ) = scope.launch {
+ storage.value.getTabs().collect { tabs ->
+ store.dispatch(RecentlyClosedAction.ReplaceTabsAction(tabs))
+ }
+ }
+
+ private fun addTabsToStorage(
+ tabList: List<RecoverableTab>,
+ ) = scope.launch {
+ storage.value.addTabsToCollectionWithMax(
+ tabList,
+ maxSavedTabs,
+ )
+ }
+
+ private fun removeTab(
+ action: RecentlyClosedAction.RemoveClosedTabAction,
+ ) = scope.launch {
+ storage.value.removeTab(action.tab)
+ }
+
+ private fun removeAllTabs() = scope.launch {
+ storage.value.removeAllTabs()
+ }
+
+ /**
+ * Interface for a storage saving snapshots of recently closed tabs / sessions.
+ */
+ interface Storage {
+ /**
+ * Returns an observable list of recently closed tabs as List of [RecoverableTab]s.
+ */
+ suspend fun getTabs(): Flow<List<TabState>>
+
+ /**
+ * Removes the given saved [RecoverableTab].
+ */
+ suspend fun removeTab(recentlyClosedTab: TabState)
+
+ /**
+ * Removes all saved [RecoverableTab]s.
+ */
+ suspend fun removeAllTabs()
+
+ /**
+ * Adds up to [maxTabs] [TabSessionState]s to storage, and then prunes storage to keep only
+ * the newest [maxTabs].
+ */
+ suspend fun addTabsToCollectionWithMax(tabs: List<RecoverableTab>, maxTabs: Int)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorage.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorage.kt
new file mode 100644
index 0000000000..ee4f68f8fa
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorage.kt
@@ -0,0 +1,136 @@
+/* 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.recentlyclosed
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.session.storage.FileEngineSessionStateStorage
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSessionStateStorage
+import mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase
+import mozilla.components.feature.recentlyclosed.db.toRecentlyClosedTabEntity
+import mozilla.components.support.base.log.logger.Logger
+
+/**
+ * Wraps exceptions that are caught by [RecentlyClosedTabsStorage].
+ * Instances of this class are submitted via [CrashReporting]. This wrapping helps easily identify
+ * exceptions related to [RecentlyClosedTabsStorage].
+ */
+private class RecentlyClosedTabsStorageException(e: Throwable) : Throwable(e)
+
+/**
+ * A storage implementation that saves snapshots of recently closed tabs / sessions.
+ */
+class RecentlyClosedTabsStorage(
+ context: Context,
+ engine: Engine,
+ private val crashReporting: CrashReporting,
+ private val engineStateStorage: EngineSessionStateStorage = FileEngineSessionStateStorage(context, engine),
+) : RecentlyClosedMiddleware.Storage {
+ private val logger = Logger("RecentlyClosedTabsStorage")
+
+ @VisibleForTesting
+ internal var database: Lazy<RecentlyClosedTabsDatabase> =
+ lazy { RecentlyClosedTabsDatabase.get(context) }
+
+ /**
+ * Returns an observable list of [TabState]s.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun getTabs(): Flow<List<TabState>> {
+ return database.value.recentlyClosedTabDao().getTabs()
+ .catch { exception ->
+ crashReporting.submitCaughtException(RecentlyClosedTabsStorageException(exception))
+ // If the database is "corrupted" then we clean the database and also the file storage
+ // to allow for a fresh set of recently closed tabs later.
+ removeAllTabs()
+ // Inform all observers of this data that recent tabs are cleared
+ // to prevent users from trying to restore nonexistent recently closed tabs.
+ emit(emptyList())
+ }
+ .map { list ->
+ list.map { it.asTabState() }
+ }
+ }
+
+ /**
+ * Removes the given [TabState].
+ */
+ override suspend fun removeTab(recentlyClosedTab: TabState) {
+ val entity = recentlyClosedTab.toRecentlyClosedTabEntity()
+ engineStateStorage.delete(entity.uuid)
+ database.value.recentlyClosedTabDao().deleteTab(entity)
+ }
+
+ /**
+ * Removes all [TabState]s.
+ */
+ override suspend fun removeAllTabs() {
+ engineStateStorage.deleteAll()
+ database.value.recentlyClosedTabDao().removeAllTabs()
+ }
+
+ /**
+ * Adds up to [maxTabs] [TabSessionState]s to storage, and then prunes storage to keep only the newest [maxTabs].
+ */
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun addTabsToCollectionWithMax(
+ tabs: List<RecoverableTab>,
+ maxTabs: Int,
+ ) {
+ try {
+ tabs.takeLast(maxTabs).forEach { addTabState(it) }
+ pruneTabsWithMax(maxTabs)
+ } catch (e: Exception) {
+ crashReporting.submitCaughtException(RecentlyClosedTabsStorageException(e))
+ }
+ }
+
+ /**
+ * @return An [EngineSessionStateStorage] instance used to persist engine state of tabs.
+ */
+ fun engineStateStorage(): EngineSessionStateStorage {
+ return engineStateStorage
+ }
+
+ private suspend fun pruneTabsWithMax(maxTabs: Int) {
+ val tabs = database.value.recentlyClosedTabDao().getTabs().first()
+
+ // No pruning required
+ if (tabs.size <= maxTabs) return
+
+ tabs.subList(maxTabs, tabs.size).forEach { entity ->
+ engineStateStorage.delete(entity.uuid)
+ database.value.recentlyClosedTabDao().deleteTab(entity)
+ }
+ }
+
+ @VisibleForTesting
+ internal suspend fun addTabState(tab: RecoverableTab) {
+ val entity = tab.state.toRecentlyClosedTabEntity()
+ // Even if engine session state persistence fails, degrade gracefully by storing the tab
+ // itself in the db - that will allow user to restore it with a "fresh" engine state.
+ // That's a form of data loss, but not much we can do here other than log.
+ tab.engineSessionState?.let {
+ try {
+ if (!engineStateStorage.write(entity.uuid, it)) {
+ logger.warn("Failed to write engine session state for tab UUID = ${entity.uuid}")
+ }
+ } catch (e: OutOfMemoryError) {
+ crashReporting.submitCaughtException(e)
+ logger.error("Failed to save state to disk due to OutOfMemoryError", e)
+ }
+ }
+ database.value.recentlyClosedTabDao().insertTab(entity)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabDao.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabDao.kt
new file mode 100644
index 0000000000..aaecd6ab40
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabDao.kt
@@ -0,0 +1,43 @@
+/* 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.recentlyclosed.db
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Internal DAO for accessing [RecentlyClosedTabEntity] instances.
+ */
+@Dao
+internal interface RecentlyClosedTabDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertTab(tab: RecentlyClosedTabEntity): Long
+
+ @Delete
+ fun deleteTab(tab: RecentlyClosedTabEntity)
+
+ @Transaction
+ @Query(
+ """
+ SELECT *
+ FROM recently_closed_tabs
+ ORDER BY created_at DESC
+ """,
+ )
+ fun getTabs(): Flow<List<RecentlyClosedTabEntity>>
+
+ @Transaction
+ @Query(
+ """
+ DELETE FROM recently_closed_tabs
+ """,
+ )
+ fun removeAllTabs()
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabEntity.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabEntity.kt
new file mode 100644
index 0000000000..84f60de53f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabEntity.kt
@@ -0,0 +1,52 @@
+/* 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.recentlyclosed.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import mozilla.components.browser.state.state.recover.TabState
+
+/**
+ * Internal entity representing recently closed tabs.
+ */
+@Entity(
+ tableName = "recently_closed_tabs",
+)
+internal data class RecentlyClosedTabEntity(
+ /**
+ * Generated UUID for this closed tab.
+ */
+ @PrimaryKey
+ @ColumnInfo(name = "uuid")
+ var uuid: String,
+
+ @ColumnInfo(name = "title")
+ var title: String,
+
+ @ColumnInfo(name = "url")
+ var url: String,
+
+ @ColumnInfo(name = "created_at")
+ var createdAt: Long,
+) {
+ internal fun asTabState(): TabState {
+ return TabState(
+ id = uuid,
+ title = title,
+ url = url,
+ lastAccess = createdAt,
+ )
+ }
+}
+
+internal fun TabState.toRecentlyClosedTabEntity(): RecentlyClosedTabEntity {
+ return RecentlyClosedTabEntity(
+ uuid = id,
+ title = title,
+ url = url,
+ createdAt = lastAccess,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabsDatabase.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabsDatabase.kt
new file mode 100644
index 0000000000..cd4e60e2e3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/main/java/mozilla/components/feature/recentlyclosed/db/RecentlyClosedTabsDatabase.kt
@@ -0,0 +1,36 @@
+/* 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.recentlyclosed.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+/**
+ * Internal database for storing recently closed tabs.
+ */
+@Database(entities = [RecentlyClosedTabEntity::class], version = 1)
+internal abstract class RecentlyClosedTabsDatabase : RoomDatabase() {
+ abstract fun recentlyClosedTabDao(): RecentlyClosedTabDao
+
+ companion object {
+ @Volatile
+ private var instance: RecentlyClosedTabsDatabase? = null
+
+ @Synchronized
+ fun get(context: Context): RecentlyClosedTabsDatabase {
+ instance?.let { return it }
+
+ return Room.databaseBuilder(
+ context,
+ RecentlyClosedTabsDatabase::class.java,
+ "recently_closed_tabs",
+ ).build().also {
+ instance = it
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddlewareTest.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddlewareTest.kt
new file mode 100644
index 0000000000..0efc5d1e35
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedMiddlewareTest.kt
@@ -0,0 +1,383 @@
+/* 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.recentlyclosed
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flow
+import mozilla.components.browser.state.action.RecentlyClosedAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.UndoAction
+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.concept.engine.Engine
+import mozilla.components.feature.session.middleware.undo.UndoMiddleware
+import mozilla.components.support.test.argumentCaptor
+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.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+@ExperimentalCoroutinesApi
+class RecentlyClosedMiddlewareTest {
+ lateinit var store: BrowserStore
+ lateinit var engine: Engine
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ store = mock()
+ engine = mock()
+ }
+
+ // Test tab
+ private val closedTab = RecoverableTab(
+ engineSessionState = null,
+ state = TabState(
+ id = "tab-id",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = 1234,
+ ),
+ )
+
+ @Test
+ fun `closed tab storage stores the provided tab on add tab action`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val store = BrowserStore(
+ initialState = BrowserState(),
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(RecentlyClosedAction.AddClosedTabsAction(listOf(closedTab))).joinBlocking()
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ verify(storage).addTabsToCollectionWithMax(
+ listOf(closedTab),
+ 5,
+ )
+ }
+
+ @Test
+ fun `closed tab storage adds normal tabs removed with TabListAction`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val tab = createTab("https://www.mozilla.org", private = false, id = "1234")
+ val tab2 = createTab("https://www.firefox.com", private = false, id = "5678")
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab, tab2),
+ ),
+ middleware = listOf(UndoMiddleware(mainScope = scope), middleware),
+ )
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf("1234", "5678"))).joinBlocking()
+ store.dispatch(UndoAction.ClearRecoverableTabs(store.state.undoHistory.tag)).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ val closedTabCaptor = argumentCaptor<List<RecoverableTab>>()
+ verify(storage).addTabsToCollectionWithMax(
+ closedTabCaptor.capture(),
+ eq(5),
+ )
+ assertEquals(2, closedTabCaptor.value.size)
+ assertEquals(tab.content.title, closedTabCaptor.value[0].state.title)
+ assertEquals(tab.content.url, closedTabCaptor.value[0].state.url)
+ assertEquals(tab2.content.title, closedTabCaptor.value[1].state.title)
+ assertEquals(tab2.content.url, closedTabCaptor.value[1].state.url)
+ assertEquals(
+ tab.engineState.engineSessionState,
+ closedTabCaptor.value[0].engineSessionState,
+ )
+ assertEquals(
+ tab2.engineState.engineSessionState,
+ closedTabCaptor.value[1].engineSessionState,
+ )
+ }
+
+ @Test
+ fun `closed tab storage adds a normal tab removed with TabListAction`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val tab = createTab("https://www.mozilla.org", private = false, id = "1234")
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab),
+ ),
+ middleware = listOf(UndoMiddleware(mainScope = scope), middleware),
+ )
+
+ store.dispatch(TabListAction.RemoveTabAction("1234")).joinBlocking()
+ store.dispatch(UndoAction.ClearRecoverableTabs(store.state.undoHistory.tag)).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ val closedTabCaptor = argumentCaptor<List<RecoverableTab>>()
+ verify(storage).addTabsToCollectionWithMax(
+ closedTabCaptor.capture(),
+ eq(5),
+ )
+ assertEquals(1, closedTabCaptor.value.size)
+ assertEquals(tab.content.title, closedTabCaptor.value[0].state.title)
+ assertEquals(tab.content.url, closedTabCaptor.value[0].state.url)
+ assertEquals(
+ tab.engineState.engineSessionState,
+ closedTabCaptor.value[0].engineSessionState,
+ )
+ }
+
+ @Test
+ fun `closed tab storage does not add a private tab removed with TabListAction`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val tab = createTab("https://www.mozilla.org", private = true, id = "1234")
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab),
+ ),
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(TabListAction.RemoveTabAction("1234")).joinBlocking()
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ verify(storage).getTabs()
+ verifyNoMoreInteractions(storage)
+ }
+
+ @Test
+ fun `closed tab storage adds all normals tab removed with TabListAction RemoveAllNormalTabsAction`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val tab = createTab("https://www.mozilla.org", private = false, id = "1234")
+ val tab2 = createTab("https://www.firefox.com", private = true, id = "3456")
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab, tab2),
+ ),
+ middleware = listOf(UndoMiddleware(mainScope = scope), middleware),
+ )
+
+ store.dispatch(TabListAction.RemoveAllNormalTabsAction).joinBlocking()
+ store.dispatch(UndoAction.ClearRecoverableTabs(store.state.undoHistory.tag)).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ val closedTabCaptor = argumentCaptor<List<RecoverableTab>>()
+ verify(storage).addTabsToCollectionWithMax(
+ closedTabCaptor.capture(),
+ eq(5),
+ )
+ assertEquals(1, closedTabCaptor.value.size)
+ assertEquals(tab.content.title, closedTabCaptor.value[0].state.title)
+ assertEquals(tab.content.url, closedTabCaptor.value[0].state.url)
+ assertEquals(
+ tab.engineState.engineSessionState,
+ closedTabCaptor.value[0].engineSessionState,
+ )
+ }
+
+ @Test
+ fun `closed tab storage adds all normal tabs and no private tabs removed with TabListAction RemoveAllTabsAction`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val tab = createTab("https://www.mozilla.org", private = false, id = "1234")
+ val tab2 = createTab("https://www.firefox.com", private = true, id = "3456")
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab, tab2),
+ ),
+ middleware = listOf(UndoMiddleware(mainScope = scope), middleware),
+ )
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+ store.dispatch(UndoAction.ClearRecoverableTabs(store.state.undoHistory.tag)).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ val closedTabCaptor = argumentCaptor<List<RecoverableTab>>()
+ verify(storage).addTabsToCollectionWithMax(
+ closedTabCaptor.capture(),
+ eq(5),
+ )
+ assertEquals(1, closedTabCaptor.value.size)
+ assertEquals(tab.content.title, closedTabCaptor.value[0].state.title)
+ assertEquals(tab.content.url, closedTabCaptor.value[0].state.url)
+ assertEquals(
+ tab.engineState.engineSessionState,
+ closedTabCaptor.value[0].engineSessionState,
+ )
+ }
+
+ @Test
+ fun `closed tabs storage adds tabs closed one after the other without clear actions in between`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val store = BrowserStore(
+ middleware = listOf(UndoMiddleware(mainScope = scope), middleware),
+ )
+
+ store.dispatch(TabListAction.AddTabAction(createTab("https://www.mozilla.org", id = "tab1"))).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(createTab("https://www.firefox.com", id = "tab2"))).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(createTab("https://getpocket.com", id = "tab3"))).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(createTab("https://theverge.com", id = "tab4"))).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(createTab("https://www.google.com", id = "tab5"))).joinBlocking()
+ assertEquals(5, store.state.tabs.size)
+
+ store.dispatch(TabListAction.RemoveTabAction("tab2")).joinBlocking()
+ store.dispatch(TabListAction.RemoveTabAction("tab3")).joinBlocking()
+ store.dispatch(TabListAction.RemoveTabAction("tab1")).joinBlocking()
+ store.dispatch(TabListAction.RemoveTabAction("tab5")).joinBlocking()
+
+ store.dispatch(UndoAction.ClearRecoverableTabs(store.state.undoHistory.tag)).joinBlocking()
+
+ assertEquals(1, store.state.tabs.size)
+ assertEquals("tab4", store.state.selectedTabId)
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ val closedTabCaptor = argumentCaptor<List<RecoverableTab>>()
+
+ verify(storage, times(4)).addTabsToCollectionWithMax(
+ closedTabCaptor.capture(),
+ eq(5),
+ )
+
+ val tabs = closedTabCaptor.allValues
+ assertEquals(4, tabs.size)
+
+ tabs[0].also { tab ->
+ assertEquals(1, tab.size)
+ assertEquals("tab2", tab[0].state.id)
+ }
+ tabs[1].also { tab ->
+ assertEquals(1, tab.size)
+ assertEquals("tab3", tab[0].state.id)
+ }
+ tabs[2].also { tab ->
+ assertEquals(1, tab.size)
+ assertEquals("tab1", tab[0].state.id)
+ }
+ tabs[3].also { tab ->
+ assertEquals(1, tab.size)
+ assertEquals("tab5", tab[0].state.id)
+ }
+ Unit
+ }
+
+ @Test
+ fun `fetch the tabs from the recently closed storage and load into browser state on initialize tab state action`() = runTestOnMain {
+ val storage = mockStorage(tabs = listOf(closedTab.state))
+
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+ val store = BrowserStore(initialState = BrowserState(), middleware = listOf(middleware))
+
+ // Wait for Init action of store to be processed
+ store.waitUntilIdle()
+
+ // Now wait for Middleware to process Init action and store to process action from middleware
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ verify(storage).getTabs()
+ assertEquals(closedTab.state, store.state.closedTabs[0])
+ }
+
+ @Test
+ fun `recently closed storage removes the provided tab on remove tab action`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+
+ val store = BrowserStore(
+ initialState = BrowserState(
+ closedTabs = listOf(
+ closedTab.state,
+ ),
+ ),
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(RecentlyClosedAction.RemoveClosedTabAction(closedTab.state)).joinBlocking()
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+ verify(storage).removeTab(closedTab.state)
+ }
+
+ @Test
+ fun `recently closed storage removes all tabs on remove all tabs action`() = runTestOnMain {
+ val storage = mockStorage()
+ val middleware = RecentlyClosedMiddleware(lazy { storage }, 5, scope)
+ val store = BrowserStore(
+ initialState = BrowserState(
+ closedTabs = listOf(
+ closedTab.state,
+ ),
+ ),
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction).joinBlocking()
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+
+ dispatcher.scheduler.advanceUntilIdle()
+ store.waitUntilIdle()
+ verify(storage).removeAllTabs()
+ }
+}
+
+private suspend fun mockStorage(
+ tabs: List<TabState> = emptyList(),
+): RecentlyClosedMiddleware.Storage {
+ val storage: RecentlyClosedMiddleware.Storage = mock()
+
+ whenever(storage.getTabs()).thenReturn(
+ flow {
+ emit(tabs)
+ },
+ )
+
+ return storage
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabDaoTest.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabDaoTest.kt
new file mode 100644
index 0000000000..b650a81915
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabDaoTest.kt
@@ -0,0 +1,136 @@
+/* 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.recentlyclosed
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabDao
+import mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabEntity
+import mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.UUID
+
+@ExperimentalCoroutinesApi // for runTest
+@RunWith(AndroidJUnit4::class)
+class RecentlyClosedTabDaoTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private lateinit var database: RecentlyClosedTabsDatabase
+ private lateinit var tabDao: RecentlyClosedTabDao
+
+ @Before
+ fun setUp() {
+ database = Room
+ .inMemoryDatabaseBuilder(context, RecentlyClosedTabsDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+ tabDao = database.recentlyClosedTabDao()
+ }
+
+ @Test
+ fun testAddingTabs() = runTestOnMain {
+ val tab1 = RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab One",
+ url = "https://www.mozilla.org",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 200,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ val tab2 = RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab Two",
+ url = "https://www.firefox.com",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 100,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ tabDao.getTabs().first().apply {
+ assertEquals(2, this.size)
+ assertEquals(tab1, this[0])
+ assertEquals(tab2, this[1])
+ }
+ Unit
+ }
+
+ @Test
+ fun testRemovingTab() = runTestOnMain {
+ val tab1 = RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab One",
+ url = "https://www.mozilla.org",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 200,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ val tab2 = RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab Two",
+ url = "https://www.firefox.com",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 100,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ tabDao.deleteTab(tab1)
+
+ tabDao.getTabs().first().apply {
+ assertEquals(1, this.size)
+ assertEquals(tab2, this[0])
+ }
+ Unit
+ }
+
+ @Test
+ fun testRemovingAllTabs() = runTestOnMain {
+ RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab One",
+ url = "https://www.mozilla.org",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 200,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ RecentlyClosedTabEntity(
+ title = "RecentlyClosedTab Two",
+ url = "https://www.firefox.com",
+ uuid = UUID.randomUUID().toString(),
+ createdAt = 100,
+ ).also {
+ tabDao.insertTab(it)
+ }
+
+ tabDao.removeAllTabs()
+
+ tabDao.getTabs().first().apply {
+ assertEquals(0, this.size)
+ }
+ Unit
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageTest.kt b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageTest.kt
new file mode 100644
index 0000000000..ca309552f7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/test/java/mozilla/components/feature/recentlyclosed/RecentlyClosedTabsStorageTest.kt
@@ -0,0 +1,355 @@
+/* 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.recentlyclosed
+
+import androidx.room.Room
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.EngineSessionState
+import mozilla.components.concept.engine.EngineSessionStateStorage
+import mozilla.components.feature.recentlyclosed.db.RecentlyClosedTabsDatabase
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import java.io.IOException
+
+@ExperimentalCoroutinesApi // for runTestOnMain
+@RunWith(AndroidJUnit4::class)
+class RecentlyClosedTabsStorageTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var storage: RecentlyClosedTabsStorage
+ private lateinit var engineStateStorage: TestEngineSessionStateStorage
+ private lateinit var database: RecentlyClosedTabsDatabase
+ private lateinit var crashReporting: CrashReporting
+
+ private class TestEngineSessionStateStorage() : EngineSessionStateStorage {
+ val data: MutableMap<String, EngineSessionState?> = mutableMapOf()
+ var throwsOutOfMemoryOnWrite: Boolean = false
+
+ override suspend fun write(uuid: String, state: EngineSessionState): Boolean {
+ if (throwsOutOfMemoryOnWrite) {
+ throw OutOfMemoryError()
+ }
+
+ if (uuid.contains("fail")) {
+ return false
+ }
+ if (uuid.contains("boom")) {
+ throw IllegalStateException("boom!")
+ }
+ data[uuid] = state
+ return true
+ }
+
+ override suspend fun read(uuid: String): EngineSessionState? {
+ return data[uuid]
+ }
+
+ override suspend fun delete(uuid: String) {
+ data.remove(uuid)
+ }
+
+ override suspend fun deleteAll() {
+ data.clear()
+ }
+ }
+
+ @Before
+ fun setUp() {
+ crashReporting = mock()
+ database = Room
+ .inMemoryDatabaseBuilder(testContext, RecentlyClosedTabsDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+
+ engineStateStorage = TestEngineSessionStateStorage()
+ storage = RecentlyClosedTabsStorage(
+ testContext,
+ mock(),
+ crashReporting,
+ engineStateStorage = engineStateStorage,
+ )
+ storage.database = lazy { database }
+ }
+
+ @After
+ @Throws(IOException::class)
+ fun closeDb() {
+ database.close()
+ }
+
+ @Test
+ fun testAddingTabsWithMax() = runTestOnMain {
+ // Test tab
+ val t1 = System.currentTimeMillis()
+ val closedTab = RecoverableTab(
+ engineSessionState = null,
+ state = TabState(
+ id = "first-tab",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = t1,
+ ),
+ )
+
+ // Test tab
+ val engineState2: EngineSessionState = mock()
+ val secondClosedTab = RecoverableTab(
+ engineSessionState = engineState2,
+ TabState(
+ id = "second-tab",
+ title = "Pocket",
+ url = "https://pocket.com",
+ lastAccess = t1 - 1000,
+ ),
+ )
+
+ storage.addTabsToCollectionWithMax(listOf(closedTab, secondClosedTab), 1)
+ val tabs = storage.getTabs().first()
+
+ assertEquals(1, engineStateStorage.data.size)
+ assertEquals(engineState2, engineStateStorage.data["second-tab"])
+
+ assertEquals(1, tabs.size)
+ assertEquals(secondClosedTab.state.url, tabs[0].url)
+ assertEquals(secondClosedTab.state.title, tabs[0].title)
+ assertEquals(secondClosedTab.state.lastAccess, tabs[0].lastAccess)
+
+ // Test tab
+ val engineState3: EngineSessionState = mock()
+ val thirdClosedTab = RecoverableTab(
+ engineSessionState = engineState3,
+ state = TabState(
+ id = "third-tab",
+ title = "Firefox",
+ url = "https://firefox.com",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+
+ storage.addTabsToCollectionWithMax(listOf(thirdClosedTab), 1)
+ val newTabs = storage.getTabs().first()
+
+ assertEquals(1, engineStateStorage.data.size)
+ assertEquals(engineState3, engineStateStorage.data["third-tab"])
+
+ assertEquals(1, newTabs.size)
+ assertEquals(thirdClosedTab.state.url, newTabs[0].url)
+ assertEquals(thirdClosedTab.state.title, newTabs[0].title)
+ assertEquals(thirdClosedTab.state.lastAccess, newTabs[0].lastAccess)
+ }
+
+ @Test
+ fun testAllowAddingSameTabTwice() = runTestOnMain {
+ // Test tab
+ val engineState: EngineSessionState = mock()
+ val closedTab = RecoverableTab(
+ engineSessionState = engineState,
+ state = TabState(
+ id = "first-tab",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+
+ val updatedTab = closedTab.copy(state = closedTab.state.copy(title = "updated"))
+ storage.addTabsToCollectionWithMax(listOf(closedTab), 2)
+ storage.addTabsToCollectionWithMax(listOf(updatedTab), 2)
+ val tabs = storage.getTabs().first()
+
+ assertEquals(1, engineStateStorage.data.size)
+ assertEquals(engineState, engineStateStorage.data["first-tab"])
+
+ assertEquals(1, tabs.size)
+ assertEquals(updatedTab.state.url, tabs[0].url)
+ assertEquals(updatedTab.state.title, tabs[0].title)
+ assertEquals(updatedTab.state.lastAccess, tabs[0].lastAccess)
+ }
+
+ @Test
+ fun testRemovingAllTabs() = runTestOnMain {
+ // Test tab
+ val t1 = System.currentTimeMillis()
+ val closedTab = RecoverableTab(
+ engineSessionState = mock(),
+ state = TabState(
+ id = "first-tab",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = t1,
+ ),
+ )
+
+ // Test tab
+ val secondClosedTab = RecoverableTab(
+ engineSessionState = mock(),
+ state = TabState(
+ id = "second-tab",
+ title = "Pocket",
+ url = "https://pocket.com",
+ lastAccess = t1 - 1000,
+ ),
+ )
+
+ storage.addTabsToCollectionWithMax(listOf(closedTab, secondClosedTab), 2)
+ val tabs = storage.getTabs().first()
+
+ assertEquals(2, engineStateStorage.data.size)
+ assertEquals(2, tabs.size)
+ assertEquals(closedTab.state.url, tabs[0].url)
+ assertEquals(closedTab.state.title, tabs[0].title)
+ assertEquals(closedTab.state.lastAccess, tabs[0].lastAccess)
+ assertEquals(secondClosedTab.state.url, tabs[1].url)
+ assertEquals(secondClosedTab.state.title, tabs[1].title)
+ assertEquals(secondClosedTab.state.lastAccess, tabs[1].lastAccess)
+
+ storage.removeAllTabs()
+ val newTabs = storage.getTabs().first()
+
+ assertEquals(0, engineStateStorage.data.size)
+ assertEquals(0, newTabs.size)
+ }
+
+ @Test
+ fun testRemovingOneTab() = runTestOnMain {
+ // Test tab
+ val engineState1: EngineSessionState = mock()
+ val t1 = System.currentTimeMillis()
+ val closedTab = RecoverableTab(
+ engineSessionState = engineState1,
+ state = TabState(
+ id = "first-tab",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = t1,
+ ),
+ )
+
+ // Test tab
+ val engineState2: EngineSessionState = mock()
+ val secondClosedTab = RecoverableTab(
+ engineSessionState = engineState2,
+ state = TabState(
+ id = "second-tab",
+ title = "Pocket",
+ url = "https://pocket.com",
+ lastAccess = t1 - 1000,
+ ),
+ )
+
+ storage.addTabState(closedTab)
+ storage.addTabState(secondClosedTab)
+ val tabs = storage.getTabs().first()
+
+ assertEquals(2, engineStateStorage.data.size)
+ assertEquals(2, tabs.size)
+ assertEquals(closedTab.state.url, tabs[0].url)
+ assertEquals(closedTab.state.title, tabs[0].title)
+ assertEquals(closedTab.state.lastAccess, tabs[0].lastAccess)
+ assertEquals(secondClosedTab.state.url, tabs[1].url)
+ assertEquals(secondClosedTab.state.title, tabs[1].title)
+ assertEquals(secondClosedTab.state.lastAccess, tabs[1].lastAccess)
+
+ storage.removeTab(tabs[0])
+ val newTabs = storage.getTabs().first()
+
+ assertEquals(1, engineStateStorage.data.size)
+ assertEquals(engineState2, engineStateStorage.data["second-tab"])
+ assertEquals(1, newTabs.size)
+ assertEquals(secondClosedTab.state.url, newTabs[0].url)
+ assertEquals(secondClosedTab.state.title, newTabs[0].title)
+ assertEquals(secondClosedTab.state.lastAccess, newTabs[0].lastAccess)
+ }
+
+ @Test
+ fun testAddingTabWithEngineStateStorageFailure() = runTestOnMain {
+ // 'fail' in tab's id will cause test engine session storage to fail on writing engineSessionState.
+ val closedTab = RecoverableTab(
+ engineSessionState = mock(),
+ state = TabState(
+ id = "second-tab-fail",
+ title = "Pocket",
+ url = "https://pocket.com",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+
+ storage.addTabState(closedTab)
+ val tabs = storage.getTabs().first()
+ // if it's empty, we know state write failed
+ assertEquals(0, engineStateStorage.data.size)
+ // but the tab was still written into the database.
+ assertEquals(1, tabs.size)
+ }
+
+ @Test
+ fun testAddingTabWithEngineStateStorageCausingOOM() = runTestOnMain {
+ // OutOfMemoryError on EngineSessionStateStorage::write will cause test engine session
+ // storage to fail on writing engineSessionState.
+ engineStateStorage.throwsOutOfMemoryOnWrite = true
+
+ // Test tab
+ val engineState1: EngineSessionState = mock()
+ val t1 = System.currentTimeMillis()
+ val closedTab = RecoverableTab(
+ engineSessionState = engineState1,
+ state = TabState(
+ id = "first-tab",
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ lastAccess = t1,
+ ),
+ )
+
+ storage.addTabState(closedTab)
+ val tabs = storage.getTabs().first()
+ // if it's empty, we know state write failed
+ assertEquals(0, engineStateStorage.data.size)
+ // but the tab was still written into the database.
+ assertEquals(1, tabs.size)
+ }
+
+ @Test
+ fun testEngineSessionStorageObtainable() {
+ assertEquals(engineStateStorage, storage.engineStateStorage())
+ }
+
+ @Test
+ fun testStorageFailuresAreCaught() = runTestOnMain {
+ val engineState: EngineSessionState = mock()
+ val closedTab = RecoverableTab(
+ engineSessionState = engineState,
+ state = TabState(
+ id = "second-tab-boom", // boom will cause an exception to be thrown
+ title = "Pocket",
+ url = "https://pocket.com",
+ lastAccess = System.currentTimeMillis(),
+ ),
+ )
+ try {
+ storage.addTabsToCollectionWithMax(listOf(closedTab), 2)
+ verify(crashReporting).submitCaughtException(any())
+ } catch (e: Exception) {
+ fail("Thrown exception was not caught")
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/recentlyclosed/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/recentlyclosed/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/recentlyclosed/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/recentlyclosed/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/recentlyclosed/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/recentlyclosed/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28