summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/tab-collections
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/feature/tab-collections')
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/README.md19
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/build.gradle87
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/schemas/mozilla.components.feature.tab.collections.db.TabCollectionDatabase/1.json122
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/TabCollectionStorageTest.kt493
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabCollectionDaoTest.kt158
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabDaoTest.kt115
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/Tab.kt42
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollection.kt57
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollectionStorage.kt170
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabAdapter.kt47
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabCollectionAdapter.kt77
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDao.kt63
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDatabase.kt36
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionEntity.kt28
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionWithTabs.kt19
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabDao.kt21
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabEntity.kt63
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/ext/TabsUseCases.kt86
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/test/java/mozilla/components/feature/tab/collections/ext/TabsUseCasesKtTest.kt94
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/tab-collections/src/test/resources/robolectric.properties1
23 files changed, 1825 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/tab-collections/README.md b/mobile/android/android-components/components/feature/tab-collections/README.md
new file mode 100644
index 0000000000..cef17fbcbc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Feature > Tab-Collections
+
+Feature implementation for saving, restoring and organizing collections of 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-tab-collections:{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/tab-collections/build.gradle b/mobile/android/android-components/components/feature/tab-collections/build.gradle
new file mode 100644
index 0000000000..74cd5c76b7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/build.gradle
@@ -0,0 +1,87 @@
+/* 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 {
+ androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
+ }
+
+ namespace 'mozilla.components.feature.tab.collections'
+}
+
+dependencies {
+ implementation project(':feature-tabs')
+
+ 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_paging
+ implementation ComponentsDependencies.androidx_lifecycle_livedata
+
+ implementation ComponentsDependencies.androidx_room_runtime
+ ksp ComponentsDependencies.androidx_room_compiler
+
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.kotlin_coroutines
+
+ androidTestImplementation project(':support-android-test')
+ androidTestImplementation project(':support-test-fakes')
+
+ androidTestImplementation ComponentsDependencies.androidx_room_testing
+ androidTestImplementation ComponentsDependencies.androidx_arch_core_testing
+ androidTestImplementation ComponentsDependencies.androidx_test_core
+ androidTestImplementation ComponentsDependencies.androidx_test_runner
+ androidTestImplementation ComponentsDependencies.androidx_test_rules
+ androidTestImplementation ComponentsDependencies.testing_coroutines
+}
+
+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/tab-collections/proguard-rules.pro b/mobile/android/android-components/components/feature/tab-collections/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/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/tab-collections/schemas/mozilla.components.feature.tab.collections.db.TabCollectionDatabase/1.json b/mobile/android/android-components/components/feature/tab-collections/schemas/mozilla.components.feature.tab.collections.db.TabCollectionDatabase/1.json
new file mode 100644
index 0000000000..e125b8a11f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/schemas/mozilla.components.feature.tab.collections.db.TabCollectionDatabase/1.json
@@ -0,0 +1,122 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "cf6d8bdd8e16b3f92043f9430524c80d",
+ "entities": [
+ {
+ "tableName": "tab_collections",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "tabs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `stat_file` TEXT NOT NULL, `tab_collection_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`tab_collection_id`) REFERENCES `tab_collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "stateFile",
+ "columnName": "stat_file",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tabCollectionId",
+ "columnName": "tab_collection_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_tabs_tab_collection_id",
+ "unique": false,
+ "columnNames": [
+ "tab_collection_id"
+ ],
+ "createSql": "CREATE INDEX `index_tabs_tab_collection_id` ON `${TABLE_NAME}` (`tab_collection_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "tab_collections",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "tab_collection_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, \"cf6d8bdd8e16b3f92043f9430524c80d\")"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/TabCollectionStorageTest.kt b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/TabCollectionStorageTest.kt
new file mode 100644
index 0000000000..3be60286a9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/TabCollectionStorageTest.kt
@@ -0,0 +1,493 @@
+/* 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.tab.collections
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.feature.tab.collections.db.TabCollectionDatabase
+import mozilla.components.feature.tab.collections.db.TabEntity
+import mozilla.components.support.ktx.java.io.truncateDirectory
+import mozilla.components.support.test.fakes.engine.FakeEngine
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+@ExperimentalCoroutinesApi // for runTest
+@Suppress("LargeClass") // Large test is large
+class TabCollectionStorageTest {
+ private lateinit var context: Context
+ private lateinit var storage: TabCollectionStorage
+ private lateinit var executor: ExecutorService
+
+ @get:Rule
+ var instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Before
+ fun setUp() {
+ executor = Executors.newSingleThreadExecutor()
+
+ context = ApplicationProvider.getApplicationContext()
+ val database = Room.inMemoryDatabaseBuilder(context, TabCollectionDatabase::class.java).build()
+
+ storage = TabCollectionStorage(context)
+ storage.database = lazy { database }
+ }
+
+ @After
+ fun tearDown() {
+ TabEntity.getStateDirectory(context.filesDir).truncateDirectory()
+
+ executor.shutdown()
+ }
+
+ @Test
+ fun testCreatingCollections() {
+ storage.createCollection("Empty")
+ storage.createCollection(
+ "Recipes",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+
+ val collections = getAllCollections()
+
+ assertEquals(2, collections.size)
+
+ assertEquals("Recipes", collections[0].title)
+ assertEquals(2, collections[0].tabs.size)
+
+ assertEquals("https://www.firefox.com", collections[0].tabs[0].url)
+ assertEquals("Firefox", collections[0].tabs[0].title)
+ assertEquals("https://www.mozilla.org", collections[0].tabs[1].url)
+ assertEquals("Mozilla", collections[0].tabs[1].title)
+
+ assertEquals("Empty", collections[1].title)
+ assertEquals(0, collections[1].tabs.size)
+ }
+
+ @Test
+ fun testAddingTabsToExistingCollection() {
+ storage.createCollection("Articles")
+ var id: Long?
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+ assertEquals(0, collections[0].tabs.size)
+
+ id = storage.addTabsToCollection(
+ collections[0],
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+ }
+
+ getAllCollections().let { collections ->
+ assertEquals(1L, id)
+ assertEquals(1, collections.size)
+ assertEquals(2, collections[0].tabs.size)
+
+ assertEquals("https://www.firefox.com", collections[0].tabs[0].url)
+ assertEquals("Firefox", collections[0].tabs[0].title)
+ assertEquals("https://www.mozilla.org", collections[0].tabs[1].url)
+ assertEquals("Mozilla", collections[0].tabs[1].title)
+ }
+ }
+
+ @Test
+ fun testRemovingTabsFromCollection() {
+ storage.createCollection(
+ "Articles",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+ assertEquals(2, collections[0].tabs.size)
+
+ storage.removeTabFromCollection(collections[0], collections[0].tabs[0])
+ }
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+ assertEquals(1, collections[0].tabs.size)
+
+ assertEquals("https://www.mozilla.org", collections[0].tabs[0].url)
+ assertEquals("Mozilla", collections[0].tabs[0].title)
+ }
+ }
+
+ @Test
+ fun testRenamingCollection() {
+ storage.createCollection("Articles")
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+ storage.renameCollection(collections[0], "Blog Articles")
+ }
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+ assertEquals("Blog Articles", collections[0].title)
+ }
+ }
+
+ @Test
+ fun testRemovingCollection() {
+ storage.createCollection("Articles")
+ storage.createCollection("Recipes")
+
+ getAllCollections().let { collections ->
+ assertEquals(2, collections.size)
+ assertEquals("Recipes", collections[0].title)
+ assertEquals("Articles", collections[1].title)
+
+ storage.removeCollection(collections[0])
+ }
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+
+ assertEquals("Articles", collections[0].title)
+ }
+ }
+
+ @Test
+ fun testCreatingCollectionAndRestoringState() {
+ val session1 = createTab("https://www.mozilla.org", title = "Mozilla")
+ val session2 = createTab("https://www.firefox.com", title = "Firefox")
+
+ storage.createCollection("Articles", listOf(session1, session2))
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+
+ val collection = collections[0]
+
+ val sessions = collection.restore(context, FakeEngine(), restoreSessionId = true)
+ assertEquals(2, sessions.size)
+
+ // We restored the same sessions
+ matches(session1, sessions[0])
+ matches(session2, sessions[1])
+
+ assertEquals(session1.id, sessions[0].state.id)
+ assertEquals(session2.id, sessions[1].state.id)
+ }
+
+ getAllCollections().let { collections ->
+ assertEquals(1, collections.size)
+
+ val collection = collections[0]
+
+ val sessions = collection.restore(context, FakeEngine(), restoreSessionId = false)
+ assertEquals(2, sessions.size)
+
+ // The sessions are not the same but contain the same data
+ assertNotEquals(session1, sessions[0])
+ assertNotEquals(session2, sessions[1])
+
+ assertNotEquals(session1.id, sessions[0].state.id)
+ assertNotEquals(session2.id, sessions[1].state.id)
+
+ assertEquals(session1.content.url, sessions[0].state.url)
+ assertEquals(session2.content.url, sessions[1].state.url)
+
+ assertEquals(session1.content.title, sessions[0].state.title)
+ assertEquals(session2.content.title, sessions[1].state.title)
+ }
+ }
+
+ @Test
+ @Suppress("ComplexMethod")
+ fun testGettingCollections() = runTest {
+ storage.createCollection(
+ "Articles",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ ),
+ )
+ storage.createCollection(
+ "Recipes",
+ listOf(
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+ storage.createCollection(
+ "Books",
+ listOf(
+ createTab("https://www.youtube.com", title = "YouTube"),
+ createTab("https://www.amazon.com", title = "Amazon"),
+ ),
+ )
+ storage.createCollection(
+ "News",
+ listOf(
+ createTab("https://www.google.com", title = "Google"),
+ createTab("https://www.facebook.com", title = "Facebook"),
+ ),
+ )
+ storage.createCollection(
+ "Blogs",
+ listOf(
+ createTab("https://www.wikipedia.org", title = "Wikipedia"),
+ ),
+ )
+
+ val collections = storage.getCollections().first()
+
+ assertEquals(5, collections.size)
+
+ with(collections[0]) {
+ assertEquals("Blogs", title)
+ assertEquals(1, tabs.size)
+ assertEquals("https://www.wikipedia.org", tabs[0].url)
+ assertEquals("Wikipedia", tabs[0].title)
+ }
+
+ with(collections[1]) {
+ assertEquals("News", title)
+ assertEquals(2, tabs.size)
+ assertEquals("https://www.facebook.com", tabs[0].url)
+ assertEquals("Facebook", tabs[0].title)
+ assertEquals("https://www.google.com", tabs[1].url)
+ assertEquals("Google", tabs[1].title)
+ }
+
+ with(collections[2]) {
+ assertEquals("Books", title)
+ assertEquals(2, tabs.size)
+ assertEquals("https://www.amazon.com", tabs[0].url)
+ assertEquals("Amazon", tabs[0].title)
+ assertEquals("https://www.youtube.com", tabs[1].url)
+ assertEquals("YouTube", tabs[1].title)
+ }
+
+ with(collections[3]) {
+ assertEquals("Recipes", title)
+ assertEquals(1, tabs.size)
+
+ assertEquals("https://www.firefox.com", tabs[0].url)
+ assertEquals("Firefox", tabs[0].title)
+ }
+
+ with(collections[4]) {
+ assertEquals("Articles", title)
+ assertEquals(1, tabs.size)
+
+ assertEquals("https://www.mozilla.org", tabs[0].url)
+ assertEquals("Mozilla", tabs[0].title)
+ }
+ }
+
+ @Test
+ @Suppress("ComplexMethod")
+ fun testGettingCollectionsList() = runTest {
+ storage.createCollection(
+ "Articles",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ ),
+ )
+ storage.createCollection(
+ "Recipes",
+ listOf(
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+ storage.createCollection(
+ "Books",
+ listOf(
+ createTab("https://www.youtube.com", title = "YouTube"),
+ createTab("https://www.amazon.com", title = "Amazon"),
+ ),
+ )
+ storage.createCollection(
+ "News",
+ listOf(
+ createTab("https://www.google.com", title = "Google"),
+ createTab("https://www.facebook.com", title = "Facebook"),
+ ),
+ )
+ storage.createCollection(
+ "Blogs",
+ listOf(
+ createTab("https://www.wikipedia.org", title = "Wikipedia"),
+ ),
+ )
+
+ val collections = storage.getCollectionsList()
+ assertEquals(5, collections.size)
+
+ with(collections[0]) {
+ assertEquals("Blogs", title)
+ assertEquals(1, tabs.size)
+ assertEquals("https://www.wikipedia.org", tabs[0].url)
+ assertEquals("Wikipedia", tabs[0].title)
+ }
+
+ with(collections[1]) {
+ assertEquals("News", title)
+ assertEquals(2, tabs.size)
+ assertEquals("https://www.facebook.com", tabs[0].url)
+ assertEquals("Facebook", tabs[0].title)
+ assertEquals("https://www.google.com", tabs[1].url)
+ assertEquals("Google", tabs[1].title)
+ }
+
+ with(collections[2]) {
+ assertEquals("Books", title)
+ assertEquals(2, tabs.size)
+ assertEquals("https://www.amazon.com", tabs[0].url)
+ assertEquals("Amazon", tabs[0].title)
+ assertEquals("https://www.youtube.com", tabs[1].url)
+ assertEquals("YouTube", tabs[1].title)
+ }
+
+ with(collections[3]) {
+ assertEquals("Recipes", title)
+ assertEquals(1, tabs.size)
+
+ assertEquals("https://www.firefox.com", tabs[0].url)
+ assertEquals("Firefox", tabs[0].title)
+ }
+
+ with(collections[4]) {
+ assertEquals("Articles", title)
+ assertEquals(1, tabs.size)
+
+ assertEquals("https://www.mozilla.org", tabs[0].url)
+ assertEquals("Mozilla", tabs[0].title)
+ }
+ }
+
+ @Test
+ fun testGettingTabCollectionCount() = runTest {
+ assertEquals(0, storage.getTabCollectionsCount())
+
+ storage.createCollection(
+ "Articles",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ ),
+ )
+ storage.createCollection(
+ "Recipes",
+ listOf(
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+
+ assertEquals(2, storage.getTabCollectionsCount())
+
+ val collections = storage.getCollections().first()
+ assertEquals(2, collections.size)
+
+ storage.removeCollection(collections[0])
+
+ assertEquals(1, storage.getTabCollectionsCount())
+ }
+
+ @Test
+ fun testRemovingAllCollections() {
+ storage.createCollection(
+ "Articles",
+ listOf(
+ createTab("https://www.mozilla.org", title = "Mozilla"),
+ ),
+ )
+ storage.createCollection(
+ "Recipes",
+ listOf(
+ createTab("https://www.firefox.com", title = "Firefox"),
+ ),
+ )
+
+ assertEquals(2, storage.getTabCollectionsCount())
+ assertEquals(2, TabEntity.getStateDirectory(context.filesDir).listFiles()?.size)
+
+ storage.removeAllCollections()
+
+ assertEquals(0, storage.getTabCollectionsCount())
+ assertEquals(0, TabEntity.getStateDirectory(context.filesDir).listFiles()?.size)
+ }
+
+ private fun getAllCollections(): List<TabCollection> {
+ val pagedList = mutableListOf<TabCollection>()
+ storage.getCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ return pagedList
+ }
+}
+
+/*
+class FakeEngine : Engine {
+ override val version: EngineVersion
+ get() = throw NotImplementedError("Not needed for test")
+
+ override fun createView(context: Context, attrs: AttributeSet?): EngineView =
+ throw UnsupportedOperationException()
+
+ override fun createSession(private: Boolean, contextId: String?): EngineSession =
+ throw UnsupportedOperationException()
+
+ override fun createSessionState(json: JSONObject) = FakeEngineSessionState()
+
+ override fun createSessionStateFrom(reader: JsonReader): EngineSessionState {
+ reader.beginObject()
+ reader.endObject()
+ return FakeEngineSessionState()
+ }
+
+ override fun name(): String =
+ throw UnsupportedOperationException()
+
+ override fun speculativeConnect(url: String) =
+ throw UnsupportedOperationException()
+
+ override val profiler: Profiler?
+ get() = throw NotImplementedError("Not needed for test")
+
+ override val settings: Settings = DefaultSettings()
+}
+
+class FakeEngineSessionState : EngineSessionState {
+ override fun writeTo(writer: JsonWriter) {
+ writer.beginObject()
+ writer.endObject()
+ }
+}
+ */
+
+private fun matches(state: TabSessionState, tab: RecoverableTab) {
+ assertEquals(state.content.url, tab.state.url)
+ assertEquals(state.content.title, tab.state.title)
+ assertEquals(state.id, tab.state.id)
+ assertEquals(state.parentId, tab.state.parentId)
+ assertEquals(state.contextId, tab.state.contextId)
+ assertEquals(state.lastAccess, tab.state.lastAccess)
+ assertEquals(state.readerState, tab.state.readerState)
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabCollectionDaoTest.kt b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabCollectionDaoTest.kt
new file mode 100644
index 0000000000..28bdfae5b6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabCollectionDaoTest.kt
@@ -0,0 +1,158 @@
+/* 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.tab.collections.db
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class TabCollectionDaoTest {
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private lateinit var database: TabCollectionDatabase
+ private lateinit var tabCollectionDao: TabCollectionDao
+ private lateinit var executor: ExecutorService
+
+ @get:Rule
+ var instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Before
+ fun setUp() {
+ database = Room.inMemoryDatabaseBuilder(context, TabCollectionDatabase::class.java).build()
+ tabCollectionDao = database.tabCollectionDao()
+ executor = Executors.newSingleThreadExecutor()
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ executor.shutdown()
+ }
+
+ @Test
+ fun testInsertingAndReadingCollections() {
+ val collection1 = TabCollectionEntity(title = "Collection One", updatedAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", updatedAt = 50)
+
+ tabCollectionDao.insertTabCollection(collection1)
+ tabCollectionDao.insertTabCollection(collection2)
+
+ val pagedList = mutableListOf<TabCollectionWithTabs>()
+ tabCollectionDao.getTabCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ assertEquals(2, pagedList.size)
+
+ assertEquals("Collection Two", pagedList[1].collection.title)
+ assertEquals("Collection One", pagedList[0].collection.title)
+ }
+
+ @Test
+ fun testUpdatingCollections() {
+ val collection1 = TabCollectionEntity(title = "Collection One", createdAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", createdAt = 50)
+
+ collection1.id = tabCollectionDao.insertTabCollection(collection1)
+ collection2.id = tabCollectionDao.insertTabCollection(collection2)
+
+ collection1.createdAt = 100
+ collection1.title = "Updated collection"
+
+ tabCollectionDao.updateTabCollection(collection1)
+
+ val pagedList = mutableListOf<TabCollectionWithTabs>()
+ tabCollectionDao.getTabCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ assertEquals(2, pagedList.size)
+
+ assertEquals("Updated collection", pagedList[0].collection.title)
+ assertEquals("Collection Two", pagedList[1].collection.title)
+ }
+
+ @Test
+ fun testRemovingCollections() {
+ val collection1 = TabCollectionEntity(title = "Collection One", updatedAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", updatedAt = 50)
+ val collection3 = TabCollectionEntity(title = "Collection Three", updatedAt = 75)
+
+ collection1.id = tabCollectionDao.insertTabCollection(collection1)
+ collection2.id = tabCollectionDao.insertTabCollection(collection2)
+ collection3.id = tabCollectionDao.insertTabCollection(collection3)
+
+ tabCollectionDao.deleteTabCollection(collection2)
+
+ val pagedList = mutableListOf<TabCollectionWithTabs>()
+ tabCollectionDao.getTabCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ assertEquals(2, pagedList.size)
+
+ assertEquals("Collection Three", pagedList[1].collection.title)
+ assertEquals("Collection One", pagedList[0].collection.title)
+ }
+
+ @Test
+ fun testGettingCollections() = runBlocking {
+ val collection1 = TabCollectionEntity(title = "Collection One", updatedAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", updatedAt = 50)
+
+ collection1.id = tabCollectionDao.insertTabCollection(collection1)
+ collection2.id = tabCollectionDao.insertTabCollection(collection2)
+
+ val data = tabCollectionDao.getTabCollections()
+
+ val collections = data.first()
+ assertEquals(2, collections.size)
+ assertEquals("Collection Two", collections[1].collection.title)
+ assertEquals("Collection One", collections[0].collection.title)
+ }
+
+ @Test
+ fun testGettingCollectionsList() = runBlocking {
+ val collection1 = TabCollectionEntity(title = "Collection One", updatedAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", updatedAt = 50)
+
+ collection1.id = tabCollectionDao.insertTabCollection(collection1)
+ collection2.id = tabCollectionDao.insertTabCollection(collection2)
+
+ val tabCollections = tabCollectionDao.getTabCollectionsList()
+
+ assertEquals(2, tabCollections.size)
+ assertEquals("Collection Two", tabCollections[1].collection.title)
+ assertEquals("Collection One", tabCollections[0].collection.title)
+ }
+
+ @Test
+ fun testCountingTabCollections() {
+ assertEquals(0, tabCollectionDao.countTabCollections())
+
+ val collection1 = TabCollectionEntity(title = "Collection One", createdAt = 10)
+ val collection2 = TabCollectionEntity(title = "Collection Two", createdAt = 50)
+
+ collection1.id = tabCollectionDao.insertTabCollection(collection1)
+ collection2.id = tabCollectionDao.insertTabCollection(collection2)
+
+ assertEquals(2, tabCollectionDao.countTabCollections())
+
+ tabCollectionDao.deleteTabCollection(collection2)
+
+ assertEquals(1, tabCollectionDao.countTabCollections())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabDaoTest.kt b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabDaoTest.kt
new file mode 100644
index 0000000000..d6839062cf
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/androidTest/java/mozilla/components/feature/tab/collections/db/TabDaoTest.kt
@@ -0,0 +1,115 @@
+/* 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.tab.collections.db
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import java.util.UUID
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class TabDaoTest {
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private lateinit var database: TabCollectionDatabase
+ private lateinit var tabCollectionDao: TabCollectionDao
+ private lateinit var tabDao: TabDao
+ private lateinit var executor: ExecutorService
+
+ @Before
+ fun setUp() {
+ database = Room.inMemoryDatabaseBuilder(context, TabCollectionDatabase::class.java).build()
+ tabCollectionDao = database.tabCollectionDao()
+ tabDao = database.tabDao()
+ executor = Executors.newSingleThreadExecutor()
+ }
+
+ @Test
+ fun testAddingTabsToCollection() {
+ val collection = TabCollectionEntity(title = "Collection One", createdAt = 10).also {
+ it.id = tabCollectionDao.insertTabCollection(it)
+ }
+
+ val tab1 = TabEntity(
+ title = "Tab One",
+ url = "https://www.mozilla.org",
+ stateFile = UUID.randomUUID().toString(),
+ tabCollectionId = collection.id!!,
+ createdAt = 200,
+ ).also {
+ it.id = tabDao.insertTab(it)
+ }
+
+ val tab2 = TabEntity(
+ title = "Tab Two",
+ url = "https://www.firefox.com",
+ stateFile = UUID.randomUUID().toString(),
+ tabCollectionId = collection.id!!,
+ createdAt = 100,
+ ).also {
+ it.id = tabDao.insertTab(it)
+ }
+
+ val pagedList = mutableListOf<TabCollectionWithTabs>()
+ tabCollectionDao.getTabCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ assertEquals(1, pagedList.size)
+ assertEquals(2, pagedList[0].tabs.size)
+ assertEquals(tab1, pagedList[0].tabs[0])
+ assertEquals(tab2, pagedList[0].tabs[1])
+ }
+
+ @Test
+ fun testRemovingTabFromCollection() {
+ val collection = TabCollectionEntity(title = "Collection One", createdAt = 10).also {
+ it.id = tabCollectionDao.insertTabCollection(it)
+ }
+
+ val tab1 = TabEntity(
+ title = "Tab One",
+ url = "https://www.mozilla.org",
+ stateFile = UUID.randomUUID().toString(),
+ tabCollectionId = collection.id!!,
+ createdAt = 200,
+ ).also {
+ it.id = tabDao.insertTab(it)
+ }
+
+ val tab2 = TabEntity(
+ title = "Tab Two",
+ url = "https://www.firefox.com",
+ stateFile = UUID.randomUUID().toString(),
+ tabCollectionId = collection.id!!,
+ createdAt = 100,
+ ).also {
+ it.id = tabDao.insertTab(it)
+ }
+
+ tabDao.deleteTab(tab1)
+
+ val pagedList = mutableListOf<TabCollectionWithTabs>()
+ tabCollectionDao.getTabCollectionsPaged().create().map {
+ pagedList.add(it)
+ }
+
+ assertEquals(1, pagedList.size)
+ assertEquals(1, pagedList[0].tabs.size)
+ assertEquals(tab2, pagedList[0].tabs[0])
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ executor.shutdown()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/tab-collections/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/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/tab-collections/src/main/java/mozilla/components/feature/tab/collections/Tab.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/Tab.kt
new file mode 100644
index 0000000000..308b897017
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/Tab.kt
@@ -0,0 +1,42 @@
+/* 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.tab.collections
+
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.concept.engine.Engine
+import java.io.File
+
+/**
+ * A tab of a [TabCollection].
+ */
+interface Tab {
+ /**
+ * Unique ID identifying this tab.
+ */
+ val id: Long
+
+ /**
+ * The title of the tab.
+ */
+ val title: String
+
+ /**
+ * The URL of the tab.
+ */
+ val url: String
+
+ /**
+ * Restores a single tab from this collection and returns a matching [RecoverableTab].
+ *
+ * @param restoreSessionId If true the original tab ID will be restored. Otherwise a new ID
+ * will be generated. An app may prefer to use a new ID if it expects sessions to get restored
+ * multiple times - otherwise breaking the promise of a unique ID per tab.
+ */
+ fun restore(
+ filesDir: File,
+ engine: Engine,
+ restoreSessionId: Boolean = false,
+ ): RecoverableTab?
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollection.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollection.kt
new file mode 100644
index 0000000000..25652e7c5a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollection.kt
@@ -0,0 +1,57 @@
+/* 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.tab.collections
+
+import android.content.Context
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.concept.engine.Engine
+
+/**
+ * A collection of tabs.
+ */
+interface TabCollection {
+ /**
+ * Unique ID of this tab collection.
+ */
+ val id: Long
+
+ /**
+ * Title of this tab collection.
+ */
+ val title: String
+
+ /**
+ * List of tabs in this tab collection.
+ */
+ val tabs: List<Tab>
+
+ /**
+ * Restores all tabs in this collection and returns a matching list of [RecoverableTab] objects.
+ *
+ * @param restoreSessionId If true the original ID of the tabs will be restored. Otherwise a new ID
+ * will be generated. An app may prefer to use a new ID if it expects tab to get restored multiple times -
+ * otherwise breaking the promise of a unique ID per tab.
+ */
+ fun restore(
+ context: Context,
+ engine: Engine,
+ restoreSessionId: Boolean = false,
+ ): List<RecoverableTab>
+
+ /**
+ * Restores a subset of the tabs in this collection and returns a matching list of
+ * [RecoverableTab] objects.
+ *
+ * @param restoreSessionId If true the original ID of the tabs will be restored. Otherwise a new ID
+ * will be generated. An app may prefer to use a new ID if it expects tab to get restored multiple times -
+ * otherwise breaking the promise of a unique ID per tab.
+ */
+ fun restoreSubset(
+ context: Context,
+ engine: Engine,
+ tabs: List<Tab>,
+ restoreSessionId: Boolean = false,
+ ): List<RecoverableTab>
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollectionStorage.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollectionStorage.kt
new file mode 100644
index 0000000000..6da13eb21b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/TabCollectionStorage.kt
@@ -0,0 +1,170 @@
+/* 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.tab.collections
+
+import android.content.Context
+import androidx.paging.DataSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.session.storage.serialize.BrowserStateWriter
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.feature.tab.collections.adapter.TabAdapter
+import mozilla.components.feature.tab.collections.adapter.TabCollectionAdapter
+import mozilla.components.feature.tab.collections.db.TabCollectionDatabase
+import mozilla.components.feature.tab.collections.db.TabCollectionEntity
+import mozilla.components.feature.tab.collections.db.TabEntity
+import mozilla.components.support.ktx.java.io.truncateDirectory
+import java.io.File
+import java.util.UUID
+
+/**
+ * A storage implementation that saves snapshots of tabs / sessions in named collections.
+ */
+class TabCollectionStorage(
+ context: Context,
+ private val writer: BrowserStateWriter = BrowserStateWriter(),
+ private val filesDir: File = context.filesDir,
+) {
+ internal var database: Lazy<TabCollectionDatabase> = lazy { TabCollectionDatabase.get(context) }
+
+ /**
+ * Creates a new [TabCollection] and save the state of the given [TabSessionState]s in it.
+ */
+ fun createCollection(title: String, sessions: List<TabSessionState> = emptyList()): Long? {
+ val entity = TabCollectionEntity(
+ title = title,
+ updatedAt = System.currentTimeMillis(),
+ createdAt = System.currentTimeMillis(),
+ ).also { entity ->
+ entity.id = database.value.tabCollectionDao().insertTabCollection(entity)
+ }
+
+ addTabsToCollection(entity, sessions)
+ return entity.id
+ }
+
+ /**
+ * Adds the state of the given [TabSessionState]s to the [TabCollection].
+ */
+ fun addTabsToCollection(collection: TabCollection, sessions: List<TabSessionState>): Long? {
+ val collectionEntity = (collection as TabCollectionAdapter).entity.collection
+ return addTabsToCollection(collectionEntity, sessions)
+ }
+
+ private fun addTabsToCollection(collection: TabCollectionEntity, sessions: List<TabSessionState>): Long? {
+ sessions.forEach { session ->
+ val fileName = UUID.randomUUID().toString()
+
+ val entity = TabEntity(
+ title = session.content.title,
+ url = session.content.url,
+ stateFile = fileName,
+ tabCollectionId = collection.id!!,
+ createdAt = System.currentTimeMillis(),
+ )
+
+ val success = writer.writeTab(session, entity.getStateFile(filesDir))
+ if (success) {
+ database.value.tabDao().insertTab(entity)
+ }
+ }
+
+ collection.updatedAt = System.currentTimeMillis()
+ database.value.tabCollectionDao().updateTabCollection(collection)
+ return collection.id
+ }
+
+ /**
+ * Removes the given [Tab] from the [TabCollection].
+ */
+ fun removeTabFromCollection(collection: TabCollection, tab: Tab) {
+ val collectionEntity = (collection as TabCollectionAdapter).entity.collection
+ val tabEntity = (tab as TabAdapter).entity
+
+ tabEntity.getStateFile(filesDir)
+ .delete()
+
+ database.value.tabDao().deleteTab(tabEntity)
+
+ collectionEntity.updatedAt = System.currentTimeMillis()
+ database.value.tabCollectionDao().updateTabCollection(collectionEntity)
+ }
+
+ /**
+ * Returns all [TabCollection]s as a [DataSource.Factory].
+ *
+ * A consuming app can transform the data source into a `LiveData<PagedList>` of when using RxJava2 into a
+ * `Flowable<PagedList>` or `Observable<PagedList>`, that can be observed.
+ *
+ * - https://developer.android.com/topic/libraries/architecture/paging/data
+ * - https://developer.android.com/topic/libraries/architecture/paging/ui
+ */
+ fun getCollectionsPaged(): DataSource.Factory<Int, TabCollection> = database.value
+ .tabCollectionDao()
+ .getTabCollectionsPaged()
+ .map { entity -> TabCollectionAdapter(entity) }
+
+ /**
+ * Returns the last [TabCollection] instances as a [Flow] list.
+ */
+ fun getCollections(): Flow<List<TabCollection>> {
+ return database.value.tabCollectionDao().getTabCollections().map { list ->
+ list.map { entity -> TabCollectionAdapter(entity) }
+ }
+ }
+
+ /**
+ * Returns all [TabCollection] instances as a list.
+ */
+ suspend fun getCollectionsList(): List<TabCollection> {
+ return database.value.tabCollectionDao().getTabCollectionsList().map { e ->
+ TabCollectionAdapter(e)
+ }
+ }
+
+ /**
+ * Renames a collection.
+ */
+ fun renameCollection(collection: TabCollection, title: String) {
+ val collectionEntity = (collection as TabCollectionAdapter).entity.collection
+
+ collectionEntity.title = title
+ collectionEntity.updatedAt = System.currentTimeMillis()
+
+ database.value.tabCollectionDao().updateTabCollection(collectionEntity)
+ }
+
+ /**
+ * Removes a collection and all its tabs.
+ */
+ fun removeCollection(collection: TabCollection) {
+ val collectionWithTabs = (collection as TabCollectionAdapter).entity
+
+ database.value
+ .tabCollectionDao()
+ .deleteTabCollection(collectionWithTabs.collection)
+
+ collectionWithTabs.tabs.forEach { tab ->
+ tab.getStateFile(filesDir).delete()
+ }
+ }
+
+ /**
+ * Removes all collections and all tabs.
+ */
+ fun removeAllCollections() {
+ database.value.clearAllTables()
+
+ TabEntity.getStateDirectory(filesDir)
+ .truncateDirectory()
+ }
+
+ /**
+ * Returns the number of tab collections.
+ */
+ fun getTabCollectionsCount(): Int {
+ return database.value.tabCollectionDao().countTabCollections()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabAdapter.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabAdapter.kt
new file mode 100644
index 0000000000..1810dbd4f5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabAdapter.kt
@@ -0,0 +1,47 @@
+/* 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.tab.collections.adapter
+
+import mozilla.components.browser.session.storage.serialize.BrowserStateReader
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.tab.collections.Tab
+import mozilla.components.feature.tab.collections.db.TabEntity
+import java.io.File
+
+internal class TabAdapter(
+ val entity: TabEntity,
+) : Tab {
+ override val id: Long
+ get() = entity.id!!
+
+ override val title: String
+ get() = entity.title
+
+ override val url: String
+ get() = entity.url
+
+ override fun restore(
+ filesDir: File,
+ engine: Engine,
+ restoreSessionId: Boolean,
+ ): RecoverableTab? {
+ val reader = BrowserStateReader()
+ val file = entity.getStateFile(filesDir)
+ return reader.readTab(engine, file, restoreSessionId, restoreParentId = false)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is TabAdapter) {
+ return false
+ }
+
+ return entity == other.entity
+ }
+
+ override fun hashCode(): Int {
+ return entity.hashCode()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabCollectionAdapter.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabCollectionAdapter.kt
new file mode 100644
index 0000000000..c4a2b265c8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/adapter/TabCollectionAdapter.kt
@@ -0,0 +1,77 @@
+/* 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.tab.collections.adapter
+
+import android.content.Context
+import mozilla.components.browser.session.storage.serialize.BrowserStateReader
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.tab.collections.Tab
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.tab.collections.db.TabCollectionWithTabs
+import mozilla.components.feature.tab.collections.db.TabEntity
+
+internal class TabCollectionAdapter(
+ internal val entity: TabCollectionWithTabs,
+) : TabCollection {
+ override val title: String
+ get() = entity.collection.title
+
+ override val tabs: List<Tab> by lazy {
+ entity
+ .tabs
+ .sortedByDescending { it.createdAt }
+ .map { TabAdapter(it) }
+ }
+
+ override val id: Long
+ get() = entity.collection.id!!
+
+ override fun restore(
+ context: Context,
+ engine: Engine,
+ restoreSessionId: Boolean,
+ ): List<RecoverableTab> {
+ return restore(context, engine, entity.tabs, restoreSessionId)
+ }
+
+ override fun restoreSubset(
+ context: Context,
+ engine: Engine,
+ tabs: List<Tab>,
+ restoreSessionId: Boolean,
+ ): List<RecoverableTab> {
+ val entities = entity.tabs.filter {
+ candidate ->
+ tabs.find { tab -> tab.id == candidate.id } != null
+ }
+ return restore(context, engine, entities, restoreSessionId)
+ }
+
+ private fun restore(
+ context: Context,
+ engine: Engine,
+ tabs: List<TabEntity>,
+ restoreSessionId: Boolean,
+ ): List<RecoverableTab> {
+ val reader = BrowserStateReader()
+ return tabs.mapNotNull { tab ->
+ val file = tab.getStateFile(context.filesDir)
+ reader.readTab(engine, file, restoreSessionId, restoreParentId = false)
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is TabCollectionAdapter) {
+ return false
+ }
+
+ return entity == other.entity
+ }
+
+ override fun hashCode(): Int {
+ return entity.hashCode()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDao.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDao.kt
new file mode 100644
index 0000000000..482a8d44ff
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDao.kt
@@ -0,0 +1,63 @@
+/* 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.tab.collections.db
+
+import androidx.paging.DataSource
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Internal DAO for accessing [TabCollectionEntity] instances.
+ */
+@Dao
+internal interface TabCollectionDao {
+ @Insert
+ fun insertTabCollection(collection: TabCollectionEntity): Long
+
+ @Delete
+ fun deleteTabCollection(collection: TabCollectionEntity)
+
+ @Update
+ fun updateTabCollection(collection: TabCollectionEntity)
+
+ @Transaction
+ @Query(
+ """
+ SELECT tab_collections.id, tab_collections.title, tab_collections.created_at, tab_collections.updated_at
+ FROM tab_collections LEFT JOIN tabs ON tab_collections.id = tab_collection_id
+ GROUP BY tab_collections.id
+ ORDER BY tab_collections.created_at DESC
+ """,
+ )
+ fun getTabCollectionsPaged(): DataSource.Factory<Int, TabCollectionWithTabs>
+
+ @Transaction
+ @Query(
+ """
+ SELECT *
+ FROM tab_collections
+ ORDER BY created_at DESC
+ """,
+ )
+ fun getTabCollections(): Flow<List<TabCollectionWithTabs>>
+
+ @Transaction
+ @Query(
+ """
+ SELECT *
+ FROM tab_collections
+ ORDER BY created_at DESC
+ """,
+ )
+ suspend fun getTabCollectionsList(): List<TabCollectionWithTabs>
+
+ @Query("SELECT COUNT(*) FROM tab_collections")
+ fun countTabCollections(): Int
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDatabase.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDatabase.kt
new file mode 100644
index 0000000000..3000b35721
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionDatabase.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.tab.collections.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+/**
+ * Internal database for storing collections and their tabs.
+ */
+@Database(entities = [TabCollectionEntity::class, TabEntity::class], version = 1)
+internal abstract class TabCollectionDatabase : RoomDatabase() {
+ abstract fun tabCollectionDao(): TabCollectionDao
+ abstract fun tabDao(): TabDao
+
+ companion object {
+ @Volatile private var instance: TabCollectionDatabase? = null
+
+ @Synchronized
+ fun get(context: Context): TabCollectionDatabase {
+ instance?.let { return it }
+
+ return Room.databaseBuilder(
+ context,
+ TabCollectionDatabase::class.java,
+ "tab_collections",
+ ).build().also {
+ instance = it
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionEntity.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionEntity.kt
new file mode 100644
index 0000000000..dbc2d4cf05
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionEntity.kt
@@ -0,0 +1,28 @@
+/* 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.tab.collections.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Internal entity representing a collection of tabs.
+ */
+@Entity(tableName = "tab_collections")
+internal data class TabCollectionEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ var id: Long? = null,
+
+ @ColumnInfo(name = "title")
+ var title: String,
+
+ @ColumnInfo(name = "updated_at")
+ var updatedAt: Long = System.currentTimeMillis(),
+
+ @ColumnInfo(name = "created_at")
+ var createdAt: Long = System.currentTimeMillis(),
+)
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionWithTabs.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionWithTabs.kt
new file mode 100644
index 0000000000..25ca4fe2a7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabCollectionWithTabs.kt
@@ -0,0 +1,19 @@
+/* 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.tab.collections.db
+
+import androidx.room.Embedded
+import androidx.room.Relation
+
+/**
+ * Class representing a [TabCollectionEntity] joined with its [TabEntity] instances.
+ */
+internal class TabCollectionWithTabs {
+ @Embedded
+ lateinit var collection: TabCollectionEntity
+
+ @Relation(parentColumn = "id", entityColumn = "tab_collection_id", entity = TabEntity::class)
+ lateinit var tabs: List<TabEntity>
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabDao.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabDao.kt
new file mode 100644
index 0000000000..69d3d3d410
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabDao.kt
@@ -0,0 +1,21 @@
+/* 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.tab.collections.db
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+
+/**
+ * Internal DAO for accessing [TabEntity] instances.
+ */
+@Dao
+internal interface TabDao {
+ @Insert
+ fun insertTab(tab: TabEntity): Long
+
+ @Delete
+ fun deleteTab(tab: TabEntity)
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabEntity.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabEntity.kt
new file mode 100644
index 0000000000..87ec9a2870
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/db/TabEntity.kt
@@ -0,0 +1,63 @@
+/* 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.tab.collections.db
+
+import android.util.AtomicFile
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.io.File
+
+/**
+ * Internal entity representing a tab that is part of a collection.
+ */
+@Entity(
+ tableName = "tabs",
+ foreignKeys = [
+ ForeignKey(
+ entity = TabCollectionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["tab_collection_id"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [
+ Index(value = ["tab_collection_id"]),
+ ],
+)
+internal data class TabEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ var id: Long? = null,
+
+ @ColumnInfo(name = "title")
+ var title: String,
+
+ @ColumnInfo(name = "url")
+ var url: String,
+
+ @ColumnInfo(name = "stat_file")
+ var stateFile: String,
+
+ @ColumnInfo(name = "tab_collection_id")
+ var tabCollectionId: Long,
+
+ @ColumnInfo(name = "created_at")
+ var createdAt: Long,
+) {
+ internal fun getStateFile(filesDir: File): AtomicFile {
+ return AtomicFile(File(getStateDirectory(filesDir), stateFile))
+ }
+
+ companion object {
+ internal fun getStateDirectory(filesDir: File): File {
+ return File(filesDir, "mozac.feature.tab.collections").apply {
+ mkdirs()
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/ext/TabsUseCases.kt b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/ext/TabsUseCases.kt
new file mode 100644
index 0000000000..412fdca6c5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/main/java/mozilla/components/feature/tab/collections/ext/TabsUseCases.kt
@@ -0,0 +1,86 @@
+/* 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.tab.collections.ext
+
+import mozilla.components.browser.state.action.LastAccessAction
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.tab.collections.Tab
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.tabs.TabsUseCases
+import java.io.File
+
+/**
+ * Restores the given [Tab] from a [TabCollection]. Will invoke [onTabRestored] on successful restore
+ * and [onFailure] otherwise.
+ *
+ * Will update the last accessed property of the tab if [updateLastAccess] is true.
+ */
+operator fun TabsUseCases.RestoreUseCase.invoke(
+ filesDir: File,
+ engine: Engine,
+ tab: Tab,
+ updateLastAccess: Boolean = true,
+ onTabRestored: (String) -> Unit,
+ onFailure: () -> Unit,
+) {
+ val item = tab.restore(
+ filesDir = filesDir,
+ engine = engine,
+ restoreSessionId = false,
+ )
+
+ if (item == null) {
+ // We were unable to restore the tab. Let the app know so that it can workaround that
+ onFailure()
+ } else {
+ invoke(listOf(item), item.state.id)
+
+ if (updateLastAccess) {
+ store.dispatch(LastAccessAction.UpdateLastAccessAction(item.state.id))
+ }
+
+ onTabRestored(item.state.id)
+ }
+}
+
+/**
+ * Restores the given [TabCollection].
+ *
+ * Will invoke [onFailure] if restoring a single [Tab] of the collection failed. The URL of the
+ * tab will be passed to [onFailure].
+ *
+ * Will update the last accessed property of the tab if [updateLastAccess] is true.
+ */
+operator fun TabsUseCases.RestoreUseCase.invoke(
+ filesDir: File,
+ engine: Engine,
+ collection: TabCollection,
+ updateLastAccess: Boolean = true,
+ onFailure: (String) -> Unit,
+) {
+ val tabs = collection.tabs.reversed().mapNotNull { tab ->
+ val recoverableTab = tab.restore(filesDir, engine, restoreSessionId = false)
+ if (recoverableTab == null) {
+ // We were unable to restore the tab. Let the app know so that it can workaround that
+ onFailure(tab.url)
+ }
+ recoverableTab
+ }
+
+ if (tabs.isEmpty()) {
+ return
+ }
+
+ invoke(tabs, selectTabId = tabs.firstOrNull()?.state?.id)
+
+ if (!updateLastAccess) {
+ return
+ }
+
+ val restoredTabIds = tabs.map { it.state.id }
+ restoredTabIds.forEach { tabId ->
+ store.dispatch(LastAccessAction.UpdateLastAccessAction(tabId))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/test/java/mozilla/components/feature/tab/collections/ext/TabsUseCasesKtTest.kt b/mobile/android/android-components/components/feature/tab-collections/src/test/java/mozilla/components/feature/tab/collections/ext/TabsUseCasesKtTest.kt
new file mode 100644
index 0000000000..0a4f7d902d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/test/java/mozilla/components/feature/tab/collections/ext/TabsUseCasesKtTest.kt
@@ -0,0 +1,94 @@
+/* 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.tab.collections.ext
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.engine.EngineMiddleware
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.recover.toRecoverableTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.feature.tab.collections.Tab
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.tabs.TabsUseCases
+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.whenever
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyBoolean
+import java.io.File
+
+class TabsUseCasesKtTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var tabsUseCases: TabsUseCases
+ private lateinit var engine: Engine
+ private lateinit var engineSession: EngineSession
+
+ private lateinit var collection: TabCollection
+ private lateinit var tab: Tab
+ private lateinit var filesDir: File
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ engineSession = mock()
+ engine = mock()
+ filesDir = mock()
+ whenever(filesDir.path).thenReturn("/test")
+
+ whenever(engine.createSession(anyBoolean(), any())).thenReturn(engineSession)
+ store = BrowserStore(
+ middleware = EngineMiddleware.create(
+ engine = engine,
+ ),
+ )
+ tabsUseCases = TabsUseCases(store)
+
+ val recoveredTab = createTab(
+ id = "123",
+ url = "https://mozilla.org",
+ lastAccess = 3735928559L,
+ ).toRecoverableTab()
+
+ tab = mock<Tab>().apply {
+ whenever(id).thenReturn(123)
+ whenever(title).thenReturn("Firefox")
+ whenever(url).thenReturn("https://firefox.com")
+ whenever(restore(filesDir, engine, false)).thenReturn(recoveredTab)
+ }
+ collection = mock<TabCollection>().apply {
+ whenever(tabs).thenReturn(listOf(tab))
+ }
+ }
+
+ @Test
+ fun `RestoreUseCase updates last access when restoring collection`() {
+ tabsUseCases.restore.invoke(filesDir, engine, collection) {}
+
+ store.waitUntilIdle()
+
+ assertNotEquals(3735928559L, store.state.findTab("123")!!.lastAccess)
+ }
+
+ @Test
+ fun `RestoreUseCase updates last access when restoring single tab in collection`() {
+ tabsUseCases.restore.invoke(filesDir, engine, tab, onTabRestored = {}, onFailure = {})
+
+ store.waitUntilIdle()
+
+ assertNotEquals(3735928559L, store.state.findTab("123")!!.lastAccess)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/tab-collections/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/tab-collections/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/tab-collections/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/tab-collections/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/tab-collections/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/tab-collections/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28