diff options
Diffstat (limited to 'mobile/android/android-components/components/feature/tab-collections')
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 |