diff --git a/.buildconfig.yml b/.buildconfig.yml index 4e52694ba83..a85dde3e64b 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -92,6 +92,10 @@ projects: path: components/feature/toolbar description: 'Feature implementation connecting a toolbar implementation with the session module.' publish: true + feature-top-sites: + path: components/feature/top-sites + description: 'Feature implementation for saving, restoring and organizing top sites.' + publish: true feature-downloads: path: components/feature/downloads description: 'Feature implementation for apps that want to use Android downloads manager.' diff --git a/README.md b/README.md index 4831632fdce..677a89d7ed0 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ _Combined components to implement feature-specific use cases._ * 🔴 [**Toolbar**](components/feature/toolbar/README.md) - A component that connects a (concept) toolbar implementation with the browser session module. +* 🔴 [**Top Sites**](components/feature/top-sites/README.md) - Feature implementation for saving, restoring and organizing top sites. + * ⚪ [**Prompts**](components/feature/prompts/README.md) - A component that will handle all the common prompt dialogs from web content. * ⚪ [**Push**](components/feature/push/README.md) - A component that provides Autopush messages with help from a supported push service. @@ -268,7 +270,7 @@ _Sample apps using various components._ * [**Nearby Chat**](samples/nearby-chat) - An app demoing how to use the [**Nearby**](components/lib/nearby/README.md) library for peer-to-peer communication between devices. -* [**Toolbar**](samples/toolbar) - An app demoing multiple customized toolbars using the [**browser-toolbar**](components/browser/toolbar/README.md) component. +* [**Toolbar**](samples/toolbar) - An app demoing multiple customized toolbars using the [**browser-toolbar**](components/browser/toolbar/README.md) component. # Building # @@ -282,11 +284,11 @@ $ ./gradlew assemble ## Android Studio ## -If the environment variable `JAVA_HOME` is not defined, you will need to set it. If you would like to use the JDK installed by Android Studio, here's how to find it: +If the environment variable `JAVA_HOME` is not defined, you will need to set it. If you would like to use the JDK installed by Android Studio, here's how to find it: 1. Open Android Studio. 2. Select "Configure". -3. Select "Default Project Structure". You should now see the Android JDK location. +3. Select "Default Project Structure". You should now see the Android JDK location. 4. Set the environment variable `JAVA_HOME` to the location. (How you set an environment variable depends on your OS.) 5. Restart Android Studio. diff --git a/components/feature/top-sites/README.md b/components/feature/top-sites/README.md new file mode 100644 index 00000000000..fda50c997b1 --- /dev/null +++ b/components/feature/top-sites/README.md @@ -0,0 +1,19 @@ +# [Android Components](../../../README.md) > Feature > Top Sites + +Feature implementation for saving, restoring and organizing top sites. + +## 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-top-sites:{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/components/feature/top-sites/build.gradle b/components/feature/top-sites/build.gradle new file mode 100644 index 00000000000..3eac61354a8 --- /dev/null +++ b/components/feature/top-sites/build.gradle @@ -0,0 +1,76 @@ +/* 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: 'kotlin-kapt' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } + } + } + + 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()) + } +} + +dependencies { + implementation project(':concept-engine') + + implementation project(':browser-session') + + implementation project(':support-ktx') + implementation project(':support-base') + + implementation Dependencies.kotlin_stdlib + implementation Dependencies.kotlin_coroutines + + implementation Dependencies.androidx_paging + implementation Dependencies.androidx_lifecycle_extensions + kapt Dependencies.androidx_lifecycle_compiler + + implementation Dependencies.androidx_room_runtime + kapt Dependencies.androidx_room_compiler + + testImplementation project(':support-test') + + testImplementation Dependencies.androidx_test_core + testImplementation Dependencies.testing_junit + testImplementation Dependencies.testing_mockito + testImplementation Dependencies.testing_robolectric + testImplementation Dependencies.kotlin_coroutines + + androidTestImplementation project(':support-android-test') + + androidTestImplementation Dependencies.androidx_room_testing + androidTestImplementation Dependencies.androidx_arch_core_testing + androidTestImplementation Dependencies.androidx_test_core + androidTestImplementation Dependencies.androidx_test_runner + androidTestImplementation Dependencies.androidx_test_rules +} + +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/feature/top-sites/proguard-rules.pro b/components/feature/top-sites/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/components/feature/top-sites/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/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json b/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json new file mode 100644 index 00000000000..f1de827c0c5 --- /dev/null +++ b/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "ce733d9c47cd10312a1c13de8efb7e8d", + "entities": [ + { + "tableName": "top_sites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `url` TEXT 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": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce733d9c47cd10312a1c13de8efb7e8d')" + ] + } +} \ No newline at end of file diff --git a/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/TopSiteStorageTest.kt b/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/TopSiteStorageTest.kt new file mode 100644 index 00000000000..b4109c6a989 --- /dev/null +++ b/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/TopSiteStorageTest.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.top.sites + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagedList +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import mozilla.components.feature.top.sites.db.TopSiteDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Suppress("LargeClass") // Large test is large +class TopSiteStorageTest { + private lateinit var context: Context + private lateinit var storage: TopSiteStorage + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + executor = Executors.newSingleThreadExecutor() + + context = ApplicationProvider.getApplicationContext() + val database = Room.inMemoryDatabaseBuilder(context, TopSiteDatabase::class.java).build() + + storage = TopSiteStorage(context) + storage.database = lazy { database } + } + + @After + fun tearDown() { + executor.shutdown() + } + + @Test + fun testAddingTopSite() { + storage.addTopSite("Mozilla","https://www.mozilla.org") + } + + private fun getAllTopSites(): List { + val dataSource = storage.getTopSitesPaged().create() + + val pagedList = PagedList.Builder(dataSource, 10) + .setNotifyExecutor(executor) + .setFetchExecutor(executor) + .build() + + return pagedList.toList() + } +} diff --git a/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/TopSiteDaoTest.kt b/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/TopSiteDaoTest.kt new file mode 100644 index 00000000000..c74b21be925 --- /dev/null +++ b/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/TopSiteDaoTest.kt @@ -0,0 +1,95 @@ +/* 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.top.sites.db + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagedList +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.Rule +import org.junit.Test +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class TopSiteDaoTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private lateinit var database: TopSiteDatabase + private lateinit var topSiteDao: TopSiteDao + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(context, TopSiteDatabase::class.java).build() + topSiteDao = database.topSiteDao() + executor = Executors.newSingleThreadExecutor() + } + + @After + fun tearDown() { + database.close() + executor.shutdown() + } + + @Test + fun testAddingTopSite() { + val topSite = TopSiteEntity( + title = "Mozilla", + url = "https://www.mozilla.org", + createdAt = 200 + ).also { + it.id = topSiteDao.insertTopSite(it) + } + + val dataSource = topSiteDao.getTopSitesPaged().create() + + val pagedList = PagedList.Builder(dataSource, 10) + .setNotifyExecutor(executor) + .setFetchExecutor(executor) + .build() + + assertEquals(1, pagedList.size) + assertEquals(topSite, pagedList[0]!!) + } + + @Test + fun testRemovingTopSite() { + val topSite1 = TopSiteEntity( + title = "Mozilla", + url = "https://www.mozilla.org", + createdAt = 200 + ).also { + it.id = topSiteDao.insertTopSite(it) + } + + val topSite2 = TopSiteEntity( + title = "Firefox", + url = "https://www.firefox.com", + createdAt = 100 + ).also { + it.id = topSiteDao.insertTopSite(it) + } + + topSiteDao.deleteTopSite(topSite1) + + val dataSource = topSiteDao.getTopSitesPaged().create() + + val pagedList = PagedList.Builder(dataSource, 10) + .setNotifyExecutor(executor) + .setFetchExecutor(executor) + .build() + + assertEquals(1, pagedList.size) + assertEquals(topSite2, pagedList[0]) + } +} diff --git a/components/feature/top-sites/src/main/AndroidManifest.xml b/components/feature/top-sites/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..914a72c3e91 --- /dev/null +++ b/components/feature/top-sites/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt new file mode 100644 index 00000000000..a559c3d4963 --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt @@ -0,0 +1,25 @@ +/* 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.top.sites + +/** + * A top site. + */ +interface TopSite { + /** + * Unique ID of this top site. + */ + val id: Long + + /** + * The title of the top site. + */ + val title: String + + /** + * The URL of the top site. + */ + val url: String +} diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSiteStorage.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSiteStorage.kt new file mode 100644 index 00000000000..0b34f4f59cb --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSiteStorage.kt @@ -0,0 +1,62 @@ +/* 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.top.sites + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import androidx.paging.DataSource +import mozilla.components.feature.top.sites.adapter.TopSiteAdapter +import mozilla.components.feature.top.sites.db.TopSiteDatabase +import mozilla.components.feature.top.sites.db.TopSiteEntity + +/** + * A storage implementation that saves snapshots of tabs / sessions in named collections. + */ +class TopSiteStorage( + context: Context +) { + internal var database: Lazy = lazy { TopSiteDatabase.get(context) } + + /** + * Adds a new [TopSite]. + */ + fun addTopSite(title: String, url: String) { + TopSiteEntity( + title = title, + url = url, + createdAt = System.currentTimeMillis() + ).also { entity -> + entity.id = database.value.topSiteDao().insertTopSite(entity) + } + } + + /** + * Returns a [LiveData] list of all the [TopSite] instances. + */ + fun getTopSites(): LiveData> { + return Transformations.map( + database.value.topSiteDao().getTopSites() + ) { list -> + list.map { entity -> TopSiteAdapter(entity) } + } + } + + /** + * Returns all [TopSite]s as a [DataSource.Factory]. + */ + fun getTopSitesPaged(): DataSource.Factory = database.value + .topSiteDao() + .getTopSitesPaged() + .map { entity -> TopSiteAdapter(entity) } + + /** + * Removes the given [TopSite]. + */ + fun removeTopSite(site: TopSite) { + val topSiteEntity = (site as TopSiteAdapter).entity + database.value.topSiteDao().deleteTopSite(topSiteEntity) + } +} diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/adapter/TopSiteAdapter.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/adapter/TopSiteAdapter.kt new file mode 100644 index 00000000000..2584dcd7d42 --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/adapter/TopSiteAdapter.kt @@ -0,0 +1,33 @@ +/* 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.top.sites.adapter + +import mozilla.components.feature.top.sites.TopSite +import mozilla.components.feature.top.sites.db.TopSiteEntity + +internal class TopSiteAdapter( + internal val entity: TopSiteEntity +) : TopSite { + override val id: Long + get() = entity.id!! + + override val title: String + get() = entity.title + + override val url: String + get() = entity.url + + override fun equals(other: Any?): Boolean { + if (other !is TopSiteAdapter) { + return false + } + + return entity == other.entity + } + + override fun hashCode(): Int { + return entity.hashCode() + } +} diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDao.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDao.kt new file mode 100644 index 00000000000..a7d8b35f497 --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDao.kt @@ -0,0 +1,41 @@ +/* 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.top.sites.db + +import androidx.lifecycle.LiveData +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** + * Internal DAO for accessing [TopSiteEntity] instances. + */ +@Dao +internal interface TopSiteDao { + @Insert + fun insertTopSite(site: TopSiteEntity): Long + + @Delete + fun deleteTopSite(site: TopSiteEntity) + + @Transaction + @Query(""" + SELECT * + FROM top_sites + ORDER BY created_at DESC + """) + fun getTopSites(): LiveData> + + @Transaction + @Query(""" + SELECT * + FROM top_sites + ORDER BY created_at DESC + """) + fun getTopSitesPaged(): DataSource.Factory +} diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt new file mode 100644 index 00000000000..5b0d9620319 --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt @@ -0,0 +1,35 @@ +/* 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.top.sites.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * Internal database for storing top sites. + */ +@Database(entities = [TopSiteEntity::class], version = 1) +internal abstract class TopSiteDatabase : RoomDatabase() { + abstract fun topSiteDao(): TopSiteDao + + companion object { + @Volatile private var instance: TopSiteDatabase? = null + + @Synchronized + fun get(context: Context): TopSiteDatabase { + instance?.let { return it } + + return Room.databaseBuilder( + context, + TopSiteDatabase::class.java, + "top_sites" + ).build().also { + instance = it + } + } + } +} diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteEntity.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteEntity.kt new file mode 100644 index 00000000000..cba04063221 --- /dev/null +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteEntity.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.top.sites.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Internal entity representing a top site. + */ +@Entity(tableName = "top_sites") +internal data class TopSiteEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + var id: Long? = null, + + @ColumnInfo(name = "title") + var title: String, + + @ColumnInfo(name = "url") + var url: String, + + @ColumnInfo(name = "created_at") + var createdAt: Long = System.currentTimeMillis() +) diff --git a/components/feature/top-sites/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/feature/top-sites/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/feature/top-sites/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)