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