summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/top-sites
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/feature/top-sites
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/top-sites')
-rw-r--r--mobile/android/android-components/components/feature/top-sites/README.md19
-rw-r--r--mobile/android/android-components/components/feature/top-sites/build.gradle82
-rw-r--r--mobile/android/android-components/components/feature/top-sites/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json52
-rw-r--r--mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/2.json58
-rw-r--r--mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/3.json58
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/OnDevicePinnedSitesStorageTest.kt313
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/PinnedSiteDaoTest.kt118
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt140
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/PinnedSiteStorage.kt104
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt90
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesConfig.kt50
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesFeature.kt38
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesProvider.kt20
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesStorage.kt65
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesUseCases.kt67
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteDao.kt48
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteEntity.kt59
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt107
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopFrecentSiteInfo.kt21
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopSite.kt34
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/facts/TopSitesFacts.kt31
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenter.kt58
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/TopSitesPresenter.kt17
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/view/TopSitesView.kt17
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/DefaultTopSitesStorageTest.kt1169
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/PinnedSitesStorageTest.kt135
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesFeatureTest.kt34
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesUseCasesTest.kt65
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/ext/TopSiteTest.kt108
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/fact/TopSitesFactsTest.kt44
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenterTest.kt37
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/top-sites/src/test/resources/robolectric.properties1
35 files changed, 3286 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/top-sites/README.md b/mobile/android/android-components/components/feature/top-sites/README.md
new file mode 100644
index 0000000000..43da2f004b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Feature > Top Sites
+
+Feature implementation for saving and removing 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/mobile/android/android-components/components/feature/top-sites/build.gradle b/mobile/android/android-components/components/feature/top-sites/build.gradle
new file mode 100644
index 0000000000..2361376051
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/build.gradle
@@ -0,0 +1,82 @@
+/* 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.top.sites'
+}
+
+dependencies {
+ implementation project(':browser-storage-sync')
+ implementation project(':concept-toolbar')
+ implementation project(':support-ktx')
+ implementation project(':support-base')
+ implementation project(':support-utils')
+
+ 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 ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_junit
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.kotlin_coroutines
+
+ androidTestImplementation project(':support-android-test')
+
+ 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/top-sites/proguard-rules.pro b/mobile/android/android-components/components/feature/top-sites/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/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/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json b/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/1.json
new file mode 100644
index 0000000000..5cf0219da6
--- /dev/null
+++ b/mobile/android/android-components/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')"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/2.json b/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/2.json
new file mode 100644
index 0000000000..8bc2effe6e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/2.json
@@ -0,0 +1,58 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "4c6cae8272b2580de8cb444de31f27d5",
+ "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, `is_default` 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": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDefault",
+ "columnName": "is_default",
+ "affinity": "INTEGER",
+ "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, '4c6cae8272b2580de8cb444de31f27d5')"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/3.json b/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/3.json
new file mode 100644
index 0000000000..e7b4ad010e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/schemas/mozilla.components.feature.top.sites.db.TopSiteDatabase/3.json
@@ -0,0 +1,58 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "4c6cae8272b2580de8cb444de31f27d5",
+ "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, `is_default` 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": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDefault",
+ "columnName": "is_default",
+ "affinity": "INTEGER",
+ "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, '4c6cae8272b2580de8cb444de31f27d5')"
+ ]
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/OnDevicePinnedSitesStorageTest.kt b/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/OnDevicePinnedSitesStorageTest.kt
new file mode 100644
index 0000000000..ac5563190d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/OnDevicePinnedSitesStorageTest.kt
@@ -0,0 +1,313 @@
+/* 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.room.Room
+import androidx.room.testing.MigrationTestHelper
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.top.sites.db.Migrations
+import mozilla.components.feature.top.sites.db.TopSiteDatabase
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+private const val MIGRATION_TEST_DB = "migration-test"
+
+@ExperimentalCoroutinesApi // for runTest
+@Suppress("LargeClass")
+class OnDevicePinnedSitesStorageTest {
+ private lateinit var context: Context
+ private lateinit var storage: PinnedSiteStorage
+ private lateinit var executor: ExecutorService
+
+ @get:Rule
+ var instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val helper: MigrationTestHelper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ TopSiteDatabase::class.java,
+ )
+
+ @Before
+ fun setUp() {
+ executor = Executors.newSingleThreadExecutor()
+
+ context = ApplicationProvider.getApplicationContext()
+ val database = Room.inMemoryDatabaseBuilder(context, TopSiteDatabase::class.java).build()
+
+ storage = PinnedSiteStorage(context)
+ storage.database = lazy { database }
+ }
+
+ @After
+ fun tearDown() {
+ executor.shutdown()
+ }
+
+ @Test
+ fun testAddingAllDefaultSites() = runTest {
+ val defaultTopSites = listOf(
+ Pair("Mozilla", "https://www.mozilla.org"),
+ Pair("Firefox", "https://www.firefox.com"),
+ Pair("Wikipedia", "https://www.wikipedia.com"),
+ Pair("Pocket", "https://www.getpocket.com"),
+ )
+
+ storage.addAllPinnedSites(defaultTopSites, isDefault = true)
+
+ val topSites = storage.getPinnedSites()
+
+ assertEquals(4, topSites.size)
+ assertEquals(4, storage.getPinnedSitesCount())
+
+ assertEquals("Mozilla", topSites[0].title)
+ assertEquals("https://www.mozilla.org", topSites[0].url)
+ assertTrue(topSites[0] is TopSite.Default)
+ assertEquals("Firefox", topSites[1].title)
+ assertEquals("https://www.firefox.com", topSites[1].url)
+ assertTrue(topSites[1] is TopSite.Default)
+ assertEquals("Wikipedia", topSites[2].title)
+ assertEquals("https://www.wikipedia.com", topSites[2].url)
+ assertTrue(topSites[2] is TopSite.Default)
+ assertEquals("Pocket", topSites[3].title)
+ assertEquals("https://www.getpocket.com", topSites[3].url)
+ assertTrue(topSites[3] is TopSite.Default)
+ }
+
+ @Test
+ fun testAddingPinnedSite() = runTest {
+ storage.addPinnedSite("Mozilla", "https://www.mozilla.org")
+ storage.addPinnedSite("Firefox", "https://www.firefox.com", isDefault = true)
+
+ val topSites = storage.getPinnedSites()
+
+ assertEquals(2, topSites.size)
+ assertEquals(2, storage.getPinnedSitesCount())
+
+ assertEquals("Mozilla", topSites[0].title)
+ assertEquals("https://www.mozilla.org", topSites[0].url)
+ assertTrue(topSites[0] is TopSite.Pinned)
+ assertEquals("Firefox", topSites[1].title)
+ assertEquals("https://www.firefox.com", topSites[1].url)
+ assertTrue(topSites[1] is TopSite.Default)
+ }
+
+ @Test
+ fun testRemovingPinnedSites() = runTest {
+ storage.addPinnedSite("Mozilla", "https://www.mozilla.org")
+ storage.addPinnedSite("Firefox", "https://www.firefox.com")
+
+ storage.getPinnedSites().let { topSites ->
+ assertEquals(2, topSites.size)
+ assertEquals(2, storage.getPinnedSitesCount())
+
+ storage.removePinnedSite(topSites[0])
+ }
+
+ storage.getPinnedSites().let { topSites ->
+ assertEquals(1, topSites.size)
+ assertEquals(1, storage.getPinnedSitesCount())
+
+ assertEquals("Firefox", topSites[0].title)
+ assertEquals("https://www.firefox.com", topSites[0].url)
+ }
+ }
+
+ @Test
+ fun testGettingPinnedSites() = runTest {
+ storage.addPinnedSite("Mozilla", "https://www.mozilla.org")
+ storage.addPinnedSite("Firefox", "https://www.firefox.com", isDefault = true)
+
+ val topSites = storage.getPinnedSites()
+
+ assertNotNull(topSites)
+ assertEquals(2, topSites.size)
+ assertEquals(2, storage.getPinnedSitesCount())
+
+ with(topSites[0]) {
+ assertEquals("Mozilla", title)
+ assertEquals("https://www.mozilla.org", url)
+ assertTrue(this is TopSite.Pinned)
+ }
+
+ with(topSites[1]) {
+ assertEquals("Firefox", title)
+ assertEquals("https://www.firefox.com", url)
+ assertTrue(this is TopSite.Default)
+ }
+ }
+
+ @Test
+ fun testUpdatingPinnedSites() = runTest {
+ storage.addPinnedSite("Mozilla", "https://www.mozilla.org")
+ var pinnedSites = storage.getPinnedSites()
+
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, storage.getPinnedSitesCount())
+ assertEquals("https://www.mozilla.org", pinnedSites[0].url)
+ assertEquals("Mozilla", pinnedSites[0].title)
+
+ storage.updatePinnedSite(pinnedSites[0], "", "")
+
+ pinnedSites = storage.getPinnedSites()
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, storage.getPinnedSitesCount())
+ assertEquals("", pinnedSites[0].url)
+ assertEquals("", pinnedSites[0].title)
+
+ storage.updatePinnedSite(pinnedSites[0], "Mozilla Firefox", "https://www.firefox.com")
+
+ pinnedSites = storage.getPinnedSites()
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, storage.getPinnedSitesCount())
+ assertEquals("https://www.firefox.com", pinnedSites[0].url)
+ assertEquals("Mozilla Firefox", pinnedSites[0].title)
+ }
+
+ @Test
+ fun migrate1to2() {
+ val dbVersion1 = helper.createDatabase(MIGRATION_TEST_DB, 1).apply {
+ execSQL(
+ "INSERT INTO " +
+ "top_sites " +
+ "(title, url, created_at) " +
+ "VALUES " +
+ "('Mozilla','mozilla.org',1)," +
+ "('Top Articles','https://getpocket.com/fenix-top-articles',2)," +
+ "('Wikipedia','https://www.wikipedia.org/',3)," +
+ "('YouTube','https://www.youtube.com/',4)",
+ )
+ }
+
+ dbVersion1.query("SELECT * FROM top_sites").use { cursor ->
+ assertEquals(4, cursor.columnCount)
+ }
+
+ val dbVersion2 = helper.runMigrationsAndValidate(
+ MIGRATION_TEST_DB,
+ 2,
+ true,
+ Migrations.migration_1_2,
+ ).apply {
+ execSQL(
+ "INSERT INTO " +
+ "top_sites " +
+ "(title, url, is_default, created_at) " +
+ "VALUES " +
+ "('Firefox','firefox.com',1,5)," +
+ "('Monitor','https://monitor.firefox.com/',0,5)",
+ )
+ }
+
+ dbVersion2.query("SELECT * FROM top_sites").use { cursor ->
+ assertEquals(5, cursor.columnCount)
+
+ // Check is_default for Mozilla
+ cursor.moveToFirst()
+ assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+
+ // Check is_default for Top Articles
+ cursor.moveToNext()
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+
+ // Check is_default for Wikipedia
+ cursor.moveToNext()
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+
+ // Check is_default for YouTube
+ cursor.moveToNext()
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+
+ // Check is_default for Firefox
+ cursor.moveToNext()
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+
+ // Check is_default for Monitor
+ cursor.moveToNext()
+ assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+ }
+ }
+
+ @Test
+ fun migrate2to3() {
+ val dbVersion2 = helper.createDatabase(MIGRATION_TEST_DB, 2).apply {
+ execSQL(
+ "INSERT INTO " +
+ "top_sites " +
+ "(title, url, is_default, created_at) " +
+ "VALUES " +
+ "('Mozilla','mozilla.org',0,1)," +
+ "('Top Articles','https://getpocket.com/fenix-top-articles',0,2)," +
+ "('Wikipedia','https://www.wikipedia.org/',0,3)," +
+ "('YouTube','https://www.youtube.com/',0,4)",
+ )
+ }
+
+ dbVersion2.query("SELECT * FROM top_sites").use { cursor ->
+ assertEquals(5, cursor.columnCount)
+ }
+
+ val dbVersion3 = helper.runMigrationsAndValidate(
+ MIGRATION_TEST_DB,
+ 3,
+ true,
+ Migrations.migration_2_3,
+ )
+
+ dbVersion3.query("SELECT * FROM top_sites").use { cursor ->
+ assertEquals(5, cursor.columnCount)
+ assertEquals(4, cursor.count)
+
+ // Check isDefault for Mozilla
+ cursor.moveToFirst()
+ assertEquals("Mozilla", cursor.getString(cursor.getColumnIndexOrThrow("title")))
+ assertEquals("mozilla.org", cursor.getString(cursor.getColumnIndexOrThrow("url")))
+ assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("created_at")))
+
+ // Check isDefault for Top Articles
+ cursor.moveToNext()
+ assertEquals("Top Articles", cursor.getString(cursor.getColumnIndexOrThrow("title")))
+ assertEquals(
+ "https://getpocket.com/fenix-top-articles",
+ cursor.getString(cursor.getColumnIndexOrThrow("url")),
+ )
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+ assertEquals(2, cursor.getInt(cursor.getColumnIndexOrThrow("created_at")))
+
+ // Check isDefault for Wikipedia
+ cursor.moveToNext()
+ assertEquals("Wikipedia", cursor.getString(cursor.getColumnIndexOrThrow("title")))
+ assertEquals(
+ "https://www.wikipedia.org/",
+ cursor.getString(cursor.getColumnIndexOrThrow("url")),
+ )
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+ assertEquals(3, cursor.getInt(cursor.getColumnIndexOrThrow("created_at")))
+
+ // Check isDefault for YouTube
+ cursor.moveToNext()
+ assertEquals("YouTube", cursor.getString(cursor.getColumnIndexOrThrow("title")))
+ assertEquals(
+ "https://www.youtube.com/",
+ cursor.getString(cursor.getColumnIndexOrThrow("url")),
+ )
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("is_default")))
+ assertEquals(4, cursor.getInt(cursor.getColumnIndexOrThrow("created_at")))
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/PinnedSiteDaoTest.kt b/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/PinnedSiteDaoTest.kt
new file mode 100644
index 0000000000..ca146dee82
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/androidTest/java/mozilla/components/feature/top/sites/db/PinnedSiteDaoTest.kt
@@ -0,0 +1,118 @@
+/* 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.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 PinnedSiteDaoTest {
+ private val context: Context
+ get() = ApplicationProvider.getApplicationContext()
+
+ private lateinit var database: TopSiteDatabase
+ private lateinit var pinnedSiteDao: PinnedSiteDao
+ private lateinit var executor: ExecutorService
+
+ @get:Rule
+ var instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Before
+ fun setUp() {
+ database = Room.inMemoryDatabaseBuilder(context, TopSiteDatabase::class.java).build()
+ pinnedSiteDao = database.pinnedSiteDao()
+ executor = Executors.newSingleThreadExecutor()
+ }
+
+ @After
+ fun tearDown() {
+ database.close()
+ executor.shutdown()
+ }
+
+ @Test
+ fun testAddingTopSite() {
+ val topSite = PinnedSiteEntity(
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ isDefault = false,
+ createdAt = 200,
+ ).also {
+ it.id = pinnedSiteDao.insertPinnedSite(it)
+ }
+
+ val pinnedSites = pinnedSiteDao.getPinnedSites()
+
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, pinnedSiteDao.getPinnedSitesCount())
+ assertEquals(topSite, pinnedSites[0])
+ }
+
+ @Test
+ fun testUpdatingTopSite() {
+ val topSite = PinnedSiteEntity(
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ isDefault = false,
+ createdAt = 200,
+ ).also {
+ it.id = pinnedSiteDao.insertPinnedSite(it)
+ }
+
+ topSite.title = "Mozilla (IT)"
+ topSite.url = "https://www.mozilla.org/it"
+ pinnedSiteDao.updatePinnedSite(topSite)
+
+ val pinnedSites = pinnedSiteDao.getPinnedSites()
+
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, pinnedSiteDao.getPinnedSitesCount())
+ assertEquals(topSite, pinnedSites[0])
+ assertEquals(topSite.title, pinnedSites[0].title)
+ assertEquals(topSite.url, pinnedSites[0].url)
+ }
+
+ @Test
+ fun testRemovingTopSite() {
+ val topSite1 = PinnedSiteEntity(
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ isDefault = false,
+ createdAt = 200,
+ ).also {
+ it.id = pinnedSiteDao.insertPinnedSite(it)
+ }
+
+ val topSite2 = PinnedSiteEntity(
+ title = "Firefox",
+ url = "https://www.firefox.com",
+ isDefault = false,
+ createdAt = 100,
+ ).also {
+ it.id = pinnedSiteDao.insertPinnedSite(it)
+ }
+
+ var pinnedSites = pinnedSiteDao.getPinnedSites()
+
+ assertEquals(2, pinnedSites.size)
+ assertEquals(2, pinnedSiteDao.getPinnedSitesCount())
+
+ pinnedSiteDao.deletePinnedSite(topSite1)
+
+ pinnedSites = pinnedSiteDao.getPinnedSites()
+
+ assertEquals(1, pinnedSites.size)
+ assertEquals(1, pinnedSiteDao.getPinnedSitesCount())
+ assertEquals(topSite2, pinnedSites[0])
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/top-sites/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/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/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt
new file mode 100644
index 0000000000..960215abef
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt
@@ -0,0 +1,140 @@
+/* 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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.feature.top.sites.ext.hasHost
+import mozilla.components.feature.top.sites.ext.hasUrl
+import mozilla.components.feature.top.sites.ext.toTopSite
+import mozilla.components.feature.top.sites.facts.emitTopSitesCountFact
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.base.observer.Observable
+import mozilla.components.support.base.observer.ObserverRegistry
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Default implementation of [TopSitesStorage].
+ *
+ * @param pinnedSitesStorage An instance of [PinnedSiteStorage], used for storing pinned sites.
+ * @param historyStorage An instance of [PlacesHistoryStorage], used for retrieving top frecent
+ * sites from history.
+ * @param topSitesProvider An optional instance of [TopSitesProvider], used for retrieving
+ * additional top sites from a provider. The returned top sites are added before pinned sites.
+ * @param defaultTopSites A list containing a title to url pair of default top sites to be added
+ * to the [PinnedSiteStorage].
+ */
+class DefaultTopSitesStorage(
+ private val pinnedSitesStorage: PinnedSiteStorage,
+ private val historyStorage: PlacesHistoryStorage,
+ private val topSitesProvider: TopSitesProvider? = null,
+ private val defaultTopSites: List<Pair<String, String>> = listOf(),
+ coroutineContext: CoroutineContext = Dispatchers.IO,
+) : TopSitesStorage, Observable<TopSitesStorage.Observer> by ObserverRegistry() {
+
+ private var scope = CoroutineScope(coroutineContext)
+ private val logger = Logger("DefaultTopSitesStorage")
+
+ // Cache of the last retrieved top sites
+ var cachedTopSites = listOf<TopSite>()
+
+ init {
+ if (defaultTopSites.isNotEmpty()) {
+ scope.launch {
+ pinnedSitesStorage.addAllPinnedSites(defaultTopSites, isDefault = true)
+ }
+ }
+ }
+
+ override fun addTopSite(title: String, url: String, isDefault: Boolean) {
+ scope.launch {
+ pinnedSitesStorage.addPinnedSite(title, url, isDefault)
+ notifyObservers { onStorageUpdated() }
+ }
+ }
+
+ override fun removeTopSite(topSite: TopSite) {
+ scope.launch {
+ if (topSite is TopSite.Default || topSite is TopSite.Pinned) {
+ pinnedSitesStorage.removePinnedSite(topSite)
+ }
+
+ // Remove the top site from both history and pinned sites storage to avoid having it
+ // show up as a frecent site if it is a pinned site.
+ if (topSite !is TopSite.Provided) {
+ historyStorage.deleteVisitsFor(topSite.url)
+ }
+
+ notifyObservers { onStorageUpdated() }
+ }
+ }
+
+ override fun updateTopSite(topSite: TopSite, title: String, url: String) {
+ scope.launch {
+ if (topSite is TopSite.Default || topSite is TopSite.Pinned) {
+ pinnedSitesStorage.updatePinnedSite(topSite, title, url)
+ }
+
+ notifyObservers { onStorageUpdated() }
+ }
+ }
+
+ @Suppress("ComplexCondition", "TooGenericExceptionCaught")
+ override suspend fun getTopSites(
+ totalSites: Int,
+ frecencyConfig: TopSitesFrecencyConfig?,
+ providerConfig: TopSitesProviderConfig?,
+ ): List<TopSite> {
+ val topSites = ArrayList<TopSite>()
+ val pinnedSites = pinnedSitesStorage.getPinnedSites().take(totalSites)
+ var providerTopSites = emptyList<TopSite>()
+ var numSitesRequired = totalSites - pinnedSites.size
+
+ if (topSitesProvider != null &&
+ providerConfig != null &&
+ providerConfig.showProviderTopSites &&
+ pinnedSites.size < providerConfig.maxThreshold
+ ) {
+ try {
+ providerTopSites = topSitesProvider
+ .getTopSites(allowCache = true)
+ .filter { providerConfig.providerFilter?.invoke(it) ?: true }
+ .take(numSitesRequired)
+ .take(providerConfig.maxThreshold - pinnedSites.size)
+ topSites.addAll(providerTopSites)
+ numSitesRequired -= providerTopSites.size
+ } catch (e: Exception) {
+ logger.error("Failed to fetch top sites from provider", e)
+ }
+ }
+
+ topSites.addAll(pinnedSites)
+
+ if (frecencyConfig?.frecencyTresholdOption != null && numSitesRequired > 0) {
+ // Get 'totalSites' sites for duplicate entries with
+ // existing pinned sites
+ val frecentSites = historyStorage
+ .getTopFrecentSites(totalSites, frecencyConfig.frecencyTresholdOption)
+ .map { it.toTopSite() }
+ .filter {
+ !pinnedSites.hasUrl(it.url) &&
+ !providerTopSites.hasHost(it.url) &&
+ frecencyConfig.frecencyFilter?.invoke(it) ?: true
+ }
+ .take(numSitesRequired)
+
+ topSites.addAll(frecentSites)
+ }
+
+ if (topSites != cachedTopSites) {
+ emitTopSitesCountFact(pinnedSites.size)
+ cachedTopSites = topSites
+ }
+
+ return topSites
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/PinnedSiteStorage.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/PinnedSiteStorage.kt
new file mode 100644
index 0000000000..d77a599ace
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/PinnedSiteStorage.kt
@@ -0,0 +1,104 @@
+/* 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.annotation.VisibleForTesting
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.withContext
+import mozilla.components.feature.top.sites.db.PinnedSiteEntity
+import mozilla.components.feature.top.sites.db.TopSiteDatabase
+import mozilla.components.feature.top.sites.db.toPinnedSite
+
+/**
+ * A storage implementation for organizing pinned sites.
+ */
+class PinnedSiteStorage(context: Context) {
+
+ @VisibleForTesting
+ internal var currentTimeMillis: () -> Long = { System.currentTimeMillis() }
+
+ @VisibleForTesting
+ internal var database: Lazy<TopSiteDatabase> = lazy { TopSiteDatabase.get(context) }
+ private val pinnedSiteDao by lazy { database.value.pinnedSiteDao() }
+
+ /**
+ * Adds the given list pinned sites.
+ *
+ * @param topSites A list containing a title to url pair of top sites to be added.
+ * @param isDefault Whether or not the pinned site added should be a default pinned site. This
+ * is used to identify pinned sites that are added by the application.
+ */
+ suspend fun addAllPinnedSites(
+ topSites: List<Pair<String, String>>,
+ isDefault: Boolean = false,
+ ) = withContext(IO) {
+ val siteEntities = topSites.map { (title, url) ->
+ PinnedSiteEntity(
+ title = title,
+ url = url,
+ isDefault = isDefault,
+ createdAt = currentTimeMillis(),
+ )
+ }
+ pinnedSiteDao.insertAllPinnedSites(siteEntities)
+ }
+
+ /**
+ * Adds a new pinned site.
+ *
+ * @param title The title string.
+ * @param url The URL string.
+ * @param isDefault Whether or not the pinned site added should be a default pinned site. This
+ * is used to identify pinned sites that are added by the application.
+ */
+ suspend fun addPinnedSite(title: String, url: String, isDefault: Boolean = false) =
+ withContext(IO) {
+ val entity = PinnedSiteEntity(
+ title = title,
+ url = url,
+ isDefault = isDefault,
+ createdAt = currentTimeMillis(),
+ )
+ entity.id = pinnedSiteDao.insertPinnedSite(entity)
+ }
+
+ /**
+ * Returns a list of all the pinned sites.
+ */
+ suspend fun getPinnedSites(): List<TopSite> = withContext(IO) {
+ pinnedSiteDao.getPinnedSites().map { entity -> entity.toTopSite() }
+ }
+
+ /**
+ * Removes the given pinned site.
+ *
+ * @param site The pinned site.
+ */
+ suspend fun removePinnedSite(site: TopSite) = withContext(IO) {
+ pinnedSiteDao.deletePinnedSite(site.toPinnedSite())
+ }
+
+ /**
+ * Updates the given pinned site.
+ *
+ * @param site The pinned site.
+ * @param title The new title for the top site.
+ * @param url The new url for the top site.
+ */
+ suspend fun updatePinnedSite(site: TopSite, title: String, url: String) = withContext(IO) {
+ val pinnedSite = site.toPinnedSite()
+ pinnedSite.title = title
+ pinnedSite.url = url
+ pinnedSiteDao.updatePinnedSite(pinnedSite)
+ }
+
+ /**
+ * Returns a count of pinned sites.
+ */
+ suspend fun getPinnedSitesCount(): Int = withContext(IO) {
+ pinnedSiteDao.getPinnedSitesCount()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt
new file mode 100644
index 0000000000..badc544d8a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt
@@ -0,0 +1,90 @@
+/* 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.
+ */
+sealed class TopSite {
+ abstract val id: Long?
+ abstract val title: String?
+ abstract val url: String
+ abstract val createdAt: Long?
+ abstract val type: String
+
+ /**
+ * This top site was added as a default by the application.
+ *
+ * @property id Unique ID of this top site.
+ * @property title The title of the top site.
+ * @property url The URL of the top site.
+ * @property createdAt The optional date the top site was added.
+ * @property type The type name of the top site.
+ */
+ data class Default(
+ override val id: Long?,
+ override val title: String?,
+ override val url: String,
+ override val createdAt: Long?,
+ override val type: String = "DEFAULT",
+ ) : TopSite()
+
+ /**
+ * This top site was pinned by an user.
+ *
+ * @property id Unique ID of this top site.
+ * @property title The title of the top site.
+ * @property url The URL of the top site.
+ * @property createdAt The optional date the top site was added.
+ * @property type The type name of the top site.
+ */
+ data class Pinned(
+ override val id: Long?,
+ override val title: String?,
+ override val url: String,
+ override val createdAt: Long?,
+ override val type: String = "PINNED",
+ ) : TopSite()
+
+ /**
+ * This top site is auto-generated from the history storage based on the most frecent site.
+ *
+ * @property id Unique ID of this top site.
+ * @property title The title of the top site.
+ * @property url The URL of the top site.
+ * @property createdAt The optional date the top site was added.
+ * @property type The type name of the top site.
+ */
+ data class Frecent(
+ override val id: Long?,
+ override val title: String?,
+ override val url: String,
+ override val createdAt: Long?,
+ override val type: String = "FRECENT",
+ ) : TopSite()
+
+ /**
+ * This top site is provided by the [TopSitesProvider].
+ *
+ * @property id Unique ID of this top site.
+ * @property title The title of the top site.
+ * @property url The URL of the top site.
+ * @property clickUrl The click URL of the top site.
+ * @property imageUrl The image URL of the top site.
+ * @property impressionUrl The URL that needs to be fired when the top site is displayed.
+ * @property createdAt The optional date the top site was added.
+ * @property type The type name of the top site.
+ */
+ data class Provided(
+ override val id: Long?,
+ override val title: String?,
+ override val url: String,
+ val clickUrl: String,
+ val imageUrl: String,
+ val impressionUrl: String,
+ override val createdAt: Long?,
+ override val type: String = "PROVIDED",
+ ) : TopSite()
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesConfig.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesConfig.kt
new file mode 100644
index 0000000000..d9a95bf138
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesConfig.kt
@@ -0,0 +1,50 @@
+/* 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 mozilla.components.concept.storage.FrecencyThresholdOption
+
+/**
+ * Top sites configuration to specify the number of top sites to display and
+ * whether or not to include top frecent sites in the top sites feature.
+ *
+ * @property totalSites A total number of sites that will be displayed.
+ * @property frecencyConfig An instance of [TopSitesFrecencyConfig] that specifies which top
+ * frecent sites should be included.
+ * @property providerConfig An instance of [TopSitesProviderConfig] that specifies whether or
+ * not to fetch top sites from the [TopSitesProvider].
+ */
+data class TopSitesConfig(
+ val totalSites: Int,
+ val frecencyConfig: TopSitesFrecencyConfig? = null,
+ val providerConfig: TopSitesProviderConfig? = null,
+)
+
+/**
+ * Top sites provider configuration to specify whether or not to fetch top sites from the provider.
+ *
+ * @property showProviderTopSites Whether or not to display the top sites from the provider.
+ * @property maxThreshold Only fetch the top sites from the provider if the number of top sites are
+ * below the maximum threshold.
+ * @property providerFilter Optional function used to filter the top sites from the provider.
+ */
+data class TopSitesProviderConfig(
+ val showProviderTopSites: Boolean,
+ val maxThreshold: Int = Int.MAX_VALUE,
+ val providerFilter: ((TopSite) -> Boolean)? = null,
+)
+
+/**
+ * Top sites frecency configuration used to specify which top frecent sites should be included.
+ *
+ * @property frecencyTresholdOption If [frecencyTresholdOption] is specified, only visited sites with a frecency
+ * score above the given threshold will be returned. Otherwise, frecent top site results are
+ * not included.
+ * @property frecencyFilter Optional function used to filter the top frecent sites.
+ */
+data class TopSitesFrecencyConfig(
+ val frecencyTresholdOption: FrecencyThresholdOption? = null,
+ val frecencyFilter: ((TopSite) -> Boolean)? = null,
+)
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesFeature.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesFeature.kt
new file mode 100644
index 0000000000..412c1e0666
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesFeature.kt
@@ -0,0 +1,38 @@
+/* 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 mozilla.components.feature.top.sites.presenter.DefaultTopSitesPresenter
+import mozilla.components.feature.top.sites.presenter.TopSitesPresenter
+import mozilla.components.feature.top.sites.view.TopSitesView
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * View-bound feature that updates the UI when the [TopSitesStorage] is updated.
+ *
+ * @param view An implementor of [TopSitesView] that will be notified of changes to the storage.
+ * @param storage The top sites storage that stores pinned and frecent sites.
+ * @param config Lambda expression that returns [TopSitesConfig] which species the number of top
+ * sites to return and whether or not to include frequently visited sites.
+ */
+class TopSitesFeature(
+ private val view: TopSitesView,
+ val storage: TopSitesStorage,
+ val config: () -> TopSitesConfig,
+ private val presenter: TopSitesPresenter = DefaultTopSitesPresenter(
+ view,
+ storage,
+ config,
+ ),
+) : LifecycleAwareFeature {
+
+ override fun start() {
+ presenter.start()
+ }
+
+ override fun stop() {
+ presenter.stop()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesProvider.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesProvider.kt
new file mode 100644
index 0000000000..4ac8200901
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesProvider.kt
@@ -0,0 +1,20 @@
+/* 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 contract that indicates how a top sites provider must behave.
+ */
+interface TopSitesProvider {
+
+ /**
+ * Provides a list of top sites.
+ *
+ * @param allowCache Whether or not the result may be provided from a previously
+ * cached response.
+ * @return a list of top sites from the provider.
+ */
+ suspend fun getTopSites(allowCache: Boolean = true): List<TopSite>
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesStorage.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesStorage.kt
new file mode 100644
index 0000000000..b27f8a3ae0
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesStorage.kt
@@ -0,0 +1,65 @@
+/* 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 mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.support.base.observer.Observable
+
+/**
+ * Abstraction layer above the [PinnedSiteStorage] and [PlacesHistoryStorage] storages.
+ */
+interface TopSitesStorage : Observable<TopSitesStorage.Observer> {
+ /**
+ * Adds a new top site.
+ *
+ * @param title The title string.
+ * @param url The URL string.
+ * @param isDefault Whether or not the pinned site added should be a default pinned site. This
+ * is used to identify pinned sites that are added by the application.
+ */
+ fun addTopSite(title: String, url: String, isDefault: Boolean = false)
+
+ /**
+ * Removes the given [TopSite].
+ *
+ * @param topSite The top site.
+ */
+ fun removeTopSite(topSite: TopSite)
+
+ /**
+ * Updates the given [TopSite].
+ *
+ * @param topSite The top site.
+ * @param title The new title for the top site.
+ * @param url The new url for the top site.
+ */
+ fun updateTopSite(topSite: TopSite, title: String, url: String)
+
+ /**
+ * Return a unified list of top sites based on the given number of sites desired.
+ * If `frecencyConfig` is specified, fill in any missing top sites with frecent top site results.
+ *
+ * @param totalSites A total number of sites that will be retrieve if possible.
+ * @param frecencyConfig An instance of [TopSitesFrecencyConfig] that specifies which top
+ * frecent sites to be included.
+ * @param providerConfig An instance of [TopSitesProviderConfig] that specifies whether or
+ * not to fetch top sites from the [TopSitesProvider].
+ */
+ suspend fun getTopSites(
+ totalSites: Int,
+ frecencyConfig: TopSitesFrecencyConfig? = null,
+ providerConfig: TopSitesProviderConfig? = null,
+ ): List<TopSite>
+
+ /**
+ * Interface to be implemented by classes that want to observe the top site storage.
+ */
+ interface Observer {
+ /**
+ * Notify the observer when changes are made to the storage.
+ */
+ fun onStorageUpdated()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesUseCases.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesUseCases.kt
new file mode 100644
index 0000000000..c7eb2f4fe6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSitesUseCases.kt
@@ -0,0 +1,67 @@
+/* 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
+
+/**
+ * Contains use cases related to the top sites feature.
+ */
+class TopSitesUseCases(topSitesStorage: TopSitesStorage) {
+ /**
+ * Add a pinned site use case.
+ */
+ class AddPinnedSiteUseCase internal constructor(private val storage: TopSitesStorage) {
+ /**
+ * Adds a new [PinnedSite].
+ *
+ * @param title The title string.
+ * @param url The URL string.
+ */
+ operator fun invoke(title: String, url: String, isDefault: Boolean = false) {
+ storage.addTopSite(title, url, isDefault)
+ }
+ }
+
+ /**
+ * Remove a top site use case.
+ */
+ class RemoveTopSiteUseCase internal constructor(private val storage: TopSitesStorage) {
+ /**
+ * Removes the given [TopSite].
+ *
+ * @param topSite The top site.
+ */
+ operator fun invoke(topSite: TopSite) {
+ storage.removeTopSite(topSite)
+ }
+ }
+
+ /**
+ * Update a top site use case.
+ */
+ class UpdateTopSiteUseCase internal constructor(private val storage: TopSitesStorage) {
+ /**
+ * Updates the given [TopSite].
+ *
+ * @param topSite The top site.
+ * @param title The new title for the top site.
+ * @param url The new url for the top site.
+ */
+ operator fun invoke(topSite: TopSite, title: String, url: String) {
+ storage.updateTopSite(topSite, title, url)
+ }
+ }
+
+ val addPinnedSites: AddPinnedSiteUseCase by lazy {
+ AddPinnedSiteUseCase(topSitesStorage)
+ }
+
+ val removeTopSites: RemoveTopSiteUseCase by lazy {
+ RemoveTopSiteUseCase(topSitesStorage)
+ }
+
+ val updateTopSites: UpdateTopSiteUseCase by lazy {
+ UpdateTopSiteUseCase(topSitesStorage)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteDao.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteDao.kt
new file mode 100644
index 0000000000..02c672b3fd
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteDao.kt
@@ -0,0 +1,48 @@
+/* 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.annotation.WorkerThread
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+
+/**
+ * Internal DAO for accessing [PinnedSiteEntity] instances.
+ */
+@Dao
+internal interface PinnedSiteDao {
+ @WorkerThread
+ @Insert
+ fun insertPinnedSite(site: PinnedSiteEntity): Long
+
+ @WorkerThread
+ @Update
+ fun updatePinnedSite(site: PinnedSiteEntity)
+
+ @WorkerThread
+ @Delete
+ fun deletePinnedSite(site: PinnedSiteEntity)
+
+ @WorkerThread
+ @Transaction
+ fun insertAllPinnedSites(sites: List<PinnedSiteEntity>): List<Long> {
+ return sites.map { entity ->
+ val id = insertPinnedSite(entity)
+ entity.id = id
+ id
+ }
+ }
+
+ @WorkerThread
+ @Query("SELECT * FROM top_sites")
+ fun getPinnedSites(): List<PinnedSiteEntity>
+
+ @Query("SELECT COUNT(*) FROM top_sites")
+ fun getPinnedSitesCount(): Int
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteEntity.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteEntity.kt
new file mode 100644
index 0000000000..fb106bf91f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/PinnedSiteEntity.kt
@@ -0,0 +1,59 @@
+/* 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
+import mozilla.components.feature.top.sites.TopSite
+
+/**
+ * Internal entity representing a pinned site.
+ */
+@Entity(tableName = "top_sites")
+internal data class PinnedSiteEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ var id: Long? = null,
+
+ @ColumnInfo(name = "title")
+ var title: String,
+
+ @ColumnInfo(name = "url")
+ var url: String,
+
+ @ColumnInfo(name = "is_default")
+ var isDefault: Boolean = false,
+
+ @ColumnInfo(name = "created_at")
+ var createdAt: Long = System.currentTimeMillis(),
+) {
+ internal fun toTopSite(): TopSite =
+ if (isDefault) {
+ TopSite.Default(
+ id = id,
+ title = title,
+ url = url,
+ createdAt = createdAt,
+ )
+ } else {
+ TopSite.Pinned(
+ id = id,
+ title = title,
+ url = url,
+ createdAt = createdAt,
+ )
+ }
+}
+
+internal fun TopSite.toPinnedSite(): PinnedSiteEntity {
+ return PinnedSiteEntity(
+ id = id,
+ title = title ?: "",
+ url = url,
+ isDefault = this is TopSite.Default,
+ createdAt = createdAt ?: 0,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt
new file mode 100644
index 0000000000..59aa469f54
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/db/TopSiteDatabase.kt
@@ -0,0 +1,107 @@
+/* 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
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+/**
+ * Internal database for storing top sites.
+ */
+@Database(entities = [PinnedSiteEntity::class], version = 3)
+internal abstract class TopSiteDatabase : RoomDatabase() {
+ abstract fun pinnedSiteDao(): PinnedSiteDao
+
+ 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",
+ ).addMigrations(
+ Migrations.migration_1_2,
+ ).addMigrations(
+ Migrations.migration_2_3,
+ ).build().also {
+ instance = it
+ }
+ }
+ }
+}
+
+internal object Migrations {
+ val migration_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Add the new is_default column and set is_default to 0 (false) for every entry.
+ db.execSQL(
+ "ALTER TABLE top_sites ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0",
+ )
+
+ // Prior to version 2, pocket top sites, wikipedia and youtube were added as default
+ // sites in Fenix. Look for these entries and set is_default to 1 (true).
+ db.execSQL(
+ "UPDATE top_sites " +
+ "SET is_default = 1 " +
+ "WHERE url IN " +
+ "('https://getpocket.com/fenix-top-articles', " +
+ "'https://www.wikipedia.org/', " +
+ "'https://www.youtube.com/')",
+ )
+ }
+ }
+
+ @Suppress("MagicNumber")
+ val migration_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Create a temporary top sites table of version 1.
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `top_sites_temp` (" +
+ "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ "`title` TEXT NOT NULL, " +
+ "`url` TEXT NOT NULL, " +
+ "`is_default` INTEGER NOT NULL, " +
+ "`created_at` INTEGER NOT NULL)",
+ )
+
+ // Insert every entry from the old table into the temporary top sites table.
+ db.execSQL(
+ "INSERT INTO top_sites_temp (title, url, created_at, is_default) " +
+ "SELECT title, url, created_at, 0 FROM top_sites",
+ )
+
+ // Assume there are consumers of version 2 with the mismatched isDefault and is_default
+ // column name. Drop the old table.
+ db.execSQL(
+ "DROP TABLE top_sites",
+ )
+
+ // Rename the temporary table to top_sites.
+ db.execSQL(
+ "ALTER TABLE top_sites_temp RENAME TO top_sites",
+ )
+
+ // Prior to version 2, pocket top sites, wikipedia and youtube were added as default
+ // sites in Fenix. Look for these entries and set isDefault to 1 (true).
+ db.execSQL(
+ "UPDATE top_sites " +
+ "SET is_default = 1 " +
+ "WHERE url IN " +
+ "('https://getpocket.com/fenix-top-articles', " +
+ "'https://www.wikipedia.org/', " +
+ "'https://www.youtube.com/')",
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopFrecentSiteInfo.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopFrecentSiteInfo.kt
new file mode 100644
index 0000000000..9fa513d6de
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopFrecentSiteInfo.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.top.sites.ext
+
+import mozilla.components.concept.storage.TopFrecentSiteInfo
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
+
+/**
+ * Returns a [TopSite] for the given [TopFrecentSiteInfo].
+ */
+fun TopFrecentSiteInfo.toTopSite(): TopSite {
+ return TopSite.Frecent(
+ id = null,
+ title = this.title?.takeIf(String::isNotBlank) ?: this.url.tryGetHostFromUrl(),
+ url = this.url,
+ createdAt = null,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopSite.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopSite.kt
new file mode 100644
index 0000000000..c9d703484c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/ext/TopSite.kt
@@ -0,0 +1,34 @@
+/* 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.ext
+
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.support.ktx.kotlin.getRepresentativeSnippet
+import mozilla.components.support.ktx.util.URLStringUtils
+
+/**
+ * Returns true if the given url is in the list top site and false otherwise.
+ *
+ * @param url The URL string.
+ */
+fun List<TopSite>.hasUrl(url: String): Boolean {
+ for (topSite in this) {
+ // Strip the https/http and WWW prefixes from the urls.
+ if (URLStringUtils.toDisplayUrl(topSite.url) == URLStringUtils.toDisplayUrl(url)) {
+ return true
+ }
+ }
+
+ return false
+}
+
+/**
+ * Returns true if the given url host/domain is in the list top site and false otherwise.
+ *
+ * @param url The URL string.
+ */
+fun List<TopSite>.hasHost(url: String): Boolean {
+ return this.any { it.url.getRepresentativeSnippet().equals(url.getRepresentativeSnippet(), true) }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/facts/TopSitesFacts.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/facts/TopSitesFacts.kt
new file mode 100644
index 0000000000..c667613d65
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/facts/TopSitesFacts.kt
@@ -0,0 +1,31 @@
+/* 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.facts
+
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+
+/**
+ * Facts emitted for telemetry related to [TopSitesFeature]
+ */
+class TopSitesFacts {
+ /**
+ * Items that specify which portion of the [TopSitesFeature] was interacted with
+ */
+ object Items {
+ const val COUNT = "count"
+ }
+}
+
+internal fun emitTopSitesCountFact(count: Int) {
+ Fact(
+ Component.FEATURE_TOP_SITES,
+ Action.INTERACTION,
+ TopSitesFacts.Items.COUNT,
+ count.toString(),
+ ).collect()
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenter.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenter.kt
new file mode 100644
index 0000000000..f1d9adb7fc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenter.kt
@@ -0,0 +1,58 @@
+/* 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.presenter
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import mozilla.components.feature.top.sites.TopSitesConfig
+import mozilla.components.feature.top.sites.TopSitesStorage
+import mozilla.components.feature.top.sites.view.TopSitesView
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Default implementation of [TopSitesPresenter]. Connects the [TopSitesView] with the
+ * [TopSitesStorage] to update the view whenever the storage is updated.
+ *
+ * @param view An implementor of [TopSitesView] that will be notified of changes to the storage.
+ * @param storage The top sites storage that stores pinned and frecent sites.
+ * @param config Lambda expression that returns [TopSitesConfig] which species the number of top
+ * sites to return and whether or not to include frequently visited sites.
+ */
+internal class DefaultTopSitesPresenter(
+ override val view: TopSitesView,
+ override val storage: TopSitesStorage,
+ private val config: () -> TopSitesConfig,
+ coroutineContext: CoroutineContext = Dispatchers.IO,
+) : TopSitesPresenter, TopSitesStorage.Observer {
+
+ private val scope = CoroutineScope(coroutineContext)
+
+ override fun start() {
+ onStorageUpdated()
+
+ storage.register(this)
+ }
+
+ override fun stop() {
+ storage.unregister(this)
+ }
+
+ override fun onStorageUpdated() {
+ val innerConfig = config.invoke()
+
+ scope.launch {
+ val topSites = storage.getTopSites(
+ totalSites = innerConfig.totalSites,
+ frecencyConfig = innerConfig.frecencyConfig,
+ providerConfig = innerConfig.providerConfig,
+ )
+
+ scope.launch(Dispatchers.Main) {
+ view.displayTopSites(topSites)
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/TopSitesPresenter.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/TopSitesPresenter.kt
new file mode 100644
index 0000000000..92d03492bb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/presenter/TopSitesPresenter.kt
@@ -0,0 +1,17 @@
+/* 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.presenter
+
+import mozilla.components.feature.top.sites.TopSitesStorage
+import mozilla.components.feature.top.sites.view.TopSitesView
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * A presenter that connects the [TopSitesView] with the [TopSitesStorage].
+ */
+interface TopSitesPresenter : LifecycleAwareFeature {
+ val view: TopSitesView
+ val storage: TopSitesStorage
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/view/TopSitesView.kt b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/view/TopSitesView.kt
new file mode 100644
index 0000000000..58c62fff6d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/view/TopSitesView.kt
@@ -0,0 +1,17 @@
+/* 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.view
+
+import mozilla.components.feature.top.sites.TopSite
+
+/**
+ * Implemented by the application for displaying onto the UI.
+ */
+interface TopSitesView {
+ /**
+ * Updates the UI with new list of top sites.
+ */
+ fun displayTopSites(topSites: List<TopSite>)
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/DefaultTopSitesStorageTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/DefaultTopSitesStorageTest.kt
new file mode 100644
index 0000000000..db650a1b6b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/DefaultTopSitesStorageTest.kt
@@ -0,0 +1,1169 @@
+/* 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.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.concept.storage.FrecencyThresholdOption
+import mozilla.components.concept.storage.TopFrecentSiteInfo
+import mozilla.components.feature.top.sites.ext.toTopSite
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@ExperimentalCoroutinesApi
+@RunWith(AndroidJUnit4::class)
+class DefaultTopSitesStorageTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val pinnedSitesStorage: PinnedSiteStorage = mock()
+ private val historyStorage: PlacesHistoryStorage = mock()
+ private val topSitesProvider: TopSitesProvider = mock()
+
+ @Test
+ fun `default top sites are added to pinned site storage on init`() = runTestOnMain {
+ val defaultTopSites = listOf(
+ Pair("Mozilla", "https://mozilla.com"),
+ Pair("Firefox", "https://firefox.com"),
+ )
+
+ DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = defaultTopSites,
+ coroutineContext = coroutineContext,
+ )
+
+ verify(pinnedSitesStorage).addAllPinnedSites(defaultTopSites, isDefault = true)
+ }
+
+ @Test
+ fun `addPinnedSite`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ defaultTopSitesStorage.addTopSite("Mozilla", "https://mozilla.com", isDefault = false)
+
+ verify(pinnedSitesStorage).addPinnedSite(
+ "Mozilla",
+ "https://mozilla.com",
+ isDefault = false,
+ )
+ }
+
+ @Test
+ fun `removeTopSite`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val frecentSite = TopSite.Frecent(
+ id = 1,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ createdAt = 1,
+ )
+
+ defaultTopSitesStorage.removeTopSite(frecentSite)
+
+ verify(historyStorage).deleteVisitsFor(frecentSite.url)
+
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 2,
+ )
+
+ defaultTopSitesStorage.removeTopSite(pinnedSite)
+
+ verify(pinnedSitesStorage).removePinnedSite(pinnedSite)
+ verify(historyStorage).deleteVisitsFor(pinnedSite.url)
+
+ val defaultSite = TopSite.Default(
+ id = 3,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 3,
+ )
+
+ defaultTopSitesStorage.removeTopSite(defaultSite)
+
+ verify(pinnedSitesStorage).removePinnedSite(defaultSite)
+ verify(historyStorage).deleteVisitsFor(defaultSite.url)
+ }
+
+ @Test
+ fun `updateTopSite`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+
+ defaultTopSitesStorage.updateTopSite(defaultSite, "Mozilla Firefox", "https://mozilla.com")
+
+ verify(pinnedSitesStorage).updatePinnedSite(defaultSite, "Mozilla Firefox", "https://mozilla.com")
+
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+
+ defaultTopSitesStorage.updateTopSite(pinnedSite, "Wiki", "https://en.wikipedia.org/wiki/Wiki")
+
+ verify(pinnedSitesStorage).updatePinnedSite(pinnedSite, "Wiki", "https://en.wikipedia.org/wiki/Wiki")
+
+ val frecentSite = TopSite.Frecent(
+ id = 1,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ createdAt = 1,
+ )
+
+ defaultTopSitesStorage.updateTopSite(frecentSite, "Moz", "")
+
+ verify(pinnedSitesStorage, never()).updatePinnedSite(frecentSite, "Moz", "")
+ }
+
+ @Test
+ fun `GIVEN frecencyConfig and providerConfig are null WHEN getTopSites is called THEN only default and pinned sites are returned`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+ whenever(pinnedSitesStorage.getPinnedSitesCount()).thenReturn(2)
+
+ var topSites = defaultTopSitesStorage.getTopSites(totalSites = 0)
+
+ assertTrue(topSites.isEmpty())
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(totalSites = 1)
+
+ assertEquals(1, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(totalSites = 2)
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(totalSites = 5)
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN providerConfig is specified WHEN getTopSites is called THEN default, pinned and provided top sites are returned`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+ val providedSite = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+ whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite))
+
+ var topSites = defaultTopSitesStorage.getTopSites(totalSites = 0)
+
+ assertTrue(topSites.isEmpty())
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 1,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(1, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 2,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(3, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = false,
+ ),
+ )
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ maxThreshold = 8,
+ ),
+ )
+
+ assertEquals(3, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ maxThreshold = 2,
+ ),
+ )
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN providerConfig with maxThreshold is specified WHEN getTopSites is called THEN the correct number of provided top sites are returned`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite1 = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+ val pinnedSite2 = TopSite.Pinned(
+ id = 3,
+ title = "Example",
+ url = "https://example.com",
+ createdAt = 3,
+ )
+ val providedSite1 = TopSite.Provided(
+ id = 4,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val providedSite2 = TopSite.Provided(
+ id = 5,
+ title = "Pocket",
+ url = "https://pocket.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ ),
+ )
+ whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite1, providedSite2))
+
+ var topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 8,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ maxThreshold = 8,
+ ),
+ )
+
+ assertEquals(8, topSites.size)
+ assertEquals(providedSite1, topSites[0])
+ assertEquals(providedSite2, topSites[1])
+ assertEquals(defaultSite, topSites[2])
+ assertEquals(pinnedSite1, topSites[3])
+ assertEquals(pinnedSite2, topSites[4])
+ assertEquals(defaultSite, topSites[5])
+ assertEquals(pinnedSite1, topSites[6])
+ assertEquals(pinnedSite2, topSites[7])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ defaultSite,
+ ),
+ )
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 8,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ maxThreshold = 8,
+ ),
+ )
+
+ assertEquals(8, topSites.size)
+ assertEquals(providedSite1, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite1, topSites[2])
+ assertEquals(pinnedSite2, topSites[3])
+ assertEquals(defaultSite, topSites[4])
+ assertEquals(pinnedSite1, topSites[5])
+ assertEquals(pinnedSite2, topSites[6])
+ assertEquals(defaultSite, topSites[7])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ defaultSite,
+ pinnedSite1,
+ pinnedSite2,
+ defaultSite,
+ pinnedSite1,
+ ),
+ )
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 8,
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ maxThreshold = 8,
+ ),
+ )
+
+ assertEquals(8, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite1, topSites[1])
+ assertEquals(pinnedSite2, topSites[2])
+ assertEquals(defaultSite, topSites[3])
+ assertEquals(pinnedSite1, topSites[4])
+ assertEquals(pinnedSite2, topSites[5])
+ assertEquals(defaultSite, topSites[6])
+ assertEquals(pinnedSite1, topSites[7])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN frecencyConfig and providerConfig are specified WHEN getTopSites is called THEN default, pinned, provided and frecent top sites are returned`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+ val providedSite = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+ whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite))
+
+ val frecentSite1 = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(listOf(frecentSite1))
+
+ var topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 0,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertTrue(topSites.isEmpty())
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 1,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(1, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 2,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 3,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(3, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ assertEquals(4, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(frecentSite1.toTopSite(), topSites[3])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `getTopSites returns pinned and frecent sites when frecencyConfig is specified`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+ whenever(pinnedSitesStorage.getPinnedSitesCount()).thenReturn(2)
+
+ val frecentSite1 = TopFrecentSiteInfo("https://mozilla.com", "Mozilla")
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(listOf(frecentSite1))
+
+ var topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 0,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertTrue(topSites.isEmpty())
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 1,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertEquals(1, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 2,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertEquals(2, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertEquals(3, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(frecentSite1.toTopSite(), topSites[2])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ val frecentSite2 = TopFrecentSiteInfo("https://example.com", "Example")
+ val frecentSite3 = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSite1,
+ frecentSite2,
+ frecentSite3,
+ ),
+ )
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertEquals(5, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(frecentSite1.toTopSite(), topSites[2])
+ assertEquals(frecentSite2.toTopSite(), topSites[3])
+ assertEquals(frecentSite3.toTopSite(), topSites[4])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ val frecentSite4 = TopFrecentSiteInfo("https://example2.com", "Example2")
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSite1,
+ frecentSite2,
+ frecentSite3,
+ frecentSite4,
+ ),
+ )
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ assertEquals(5, topSites.size)
+ assertEquals(defaultSite, topSites[0])
+ assertEquals(pinnedSite, topSites[1])
+ assertEquals(frecentSite1.toTopSite(), topSites[2])
+ assertEquals(frecentSite2.toTopSite(), topSites[3])
+ assertEquals(frecentSite3.toTopSite(), topSites[4])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `getTopSites filters out frecent sites that already exist in pinned sites`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSiteFirefox = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite1 = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+ val pinnedSite2 = TopSite.Pinned(
+ id = 3,
+ title = "Example",
+ url = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSiteFirefox,
+ pinnedSite1,
+ pinnedSite2,
+ ),
+ )
+ whenever(pinnedSitesStorage.getPinnedSitesCount()).thenReturn(3)
+
+ val frecentSiteWithNoTitle = TopFrecentSiteInfo("https://mozilla.com", "")
+ val frecentSiteFirefox = TopFrecentSiteInfo("https://firefox.com", "Firefox")
+ val frecentSite1 = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+ val frecentSite2 = TopFrecentSiteInfo("https://www.example.com", "Example")
+
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSiteWithNoTitle,
+ frecentSiteFirefox,
+ frecentSite1,
+ frecentSite2,
+ ),
+ )
+
+ val topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ )
+
+ verify(historyStorage).getTopFrecentSites(5, frecencyThreshold = FrecencyThresholdOption.NONE)
+
+ assertEquals(5, topSites.size)
+ assertEquals(defaultSiteFirefox, topSites[0])
+ assertEquals(pinnedSite1, topSites[1])
+ assertEquals(pinnedSite2, topSites[2])
+ assertEquals(frecentSiteWithNoTitle.toTopSite(), topSites[3])
+ assertEquals(frecentSite1.toTopSite(), topSites[4])
+ assertEquals("mozilla.com", frecentSiteWithNoTitle.toTopSite().title)
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN providerFilter is set WHEN getTopSites is called THEN the provided top sites are filtered`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ coroutineContext = coroutineContext,
+ )
+
+ val filteredUrl = "https://test.com"
+
+ val providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ providerFilter = { topSite -> topSite.url != filteredUrl },
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Test",
+ url = filteredUrl,
+ createdAt = 2,
+ )
+ val providedSite = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val providedFilteredSite = TopSite.Provided(
+ id = 3,
+ title = "Filtered",
+ url = filteredUrl,
+ clickUrl = "https://test.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+ whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite, providedFilteredSite))
+
+ val frecentSite1 = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(listOf(frecentSite1))
+
+ var topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 3,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = providerConfig,
+ )
+
+ assertEquals(3, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 4,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = providerConfig,
+ )
+
+ assertEquals(4, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSite, topSites[1])
+ assertEquals(pinnedSite, topSites[2])
+ assertEquals(frecentSite1.toTopSite(), topSites[3])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN frecent top sites exist as a pinned or provided site WHEN top sites are retrieved THEN filters out frecent sites that already exist in pinned or provided sites`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSiteFirefox = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite1 = TopSite.Pinned(
+ id = 2,
+ title = "Wikipedia",
+ url = "https://wikipedia.com",
+ createdAt = 2,
+ )
+ val pinnedSite2 = TopSite.Pinned(
+ id = 3,
+ title = "Example",
+ url = "https://example.com",
+ createdAt = 3,
+ )
+ val providedSite = TopSite.Provided(
+ id = 3,
+ title = "Firefox",
+ url = "https://getfirefox.com",
+ clickUrl = "https://getfirefox.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSiteFirefox,
+ pinnedSite1,
+ pinnedSite2,
+ ),
+ )
+ whenever(pinnedSitesStorage.getPinnedSitesCount()).thenReturn(3)
+ whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite))
+
+ val frecentSiteWithNoTitle = TopFrecentSiteInfo("https://mozilla.com", "")
+ val frecentSiteFirefox = TopFrecentSiteInfo("https://firefox.com", "Firefox")
+ val frecentSite1 = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+ val frecentSite2 = TopFrecentSiteInfo("https://www.example.com", "Example")
+ val frecentSite3 = TopFrecentSiteInfo("https://www.getfirefox.com", "Firefox")
+
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSiteWithNoTitle,
+ frecentSiteFirefox,
+ frecentSite1,
+ frecentSite2,
+ frecentSite3,
+ ),
+ )
+
+ val topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 10,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ verify(historyStorage).getTopFrecentSites(10, frecencyThreshold = FrecencyThresholdOption.NONE)
+
+ assertEquals(6, topSites.size)
+ assertEquals(providedSite, topSites[0])
+ assertEquals(defaultSiteFirefox, topSites[1])
+ assertEquals(pinnedSite1, topSites[2])
+ assertEquals(pinnedSite2, topSites[3])
+ assertEquals(frecentSiteWithNoTitle.toTopSite(), topSites[4])
+ assertEquals(frecentSite1.toTopSite(), topSites[5])
+ assertEquals("mozilla.com", frecentSiteWithNoTitle.toTopSite().title)
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN frecencyFilter is set WHEN getTopSites is called THEN the frecent top sites are filtered`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ coroutineContext = coroutineContext,
+ )
+
+ val filterMethod: ((TopSite) -> Boolean) = { topSite ->
+ val uri = Uri.parse(topSite.url)
+ if (!uri.queryParameterNames.contains("key")) {
+ true
+ } else {
+ uri.getQueryParameter("key") != "value"
+ }
+ }
+
+ val filteredUrl = "https://test.com/?key=value"
+
+ val frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ frecencyFilter = filterMethod,
+ )
+
+ val defaultSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite = TopSite.Pinned(
+ id = 2,
+ title = "Test",
+ url = "https://test.com",
+ createdAt = 2,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSite,
+ pinnedSite,
+ ),
+ )
+
+ val providedFilteredSite = TopSite.Provided(
+ id = 3,
+ title = "Filtered",
+ url = "https://test.com",
+ clickUrl = "https://test.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+
+ whenever(topSitesProvider.getTopSites()).thenReturn(
+ listOf(
+ providedFilteredSite,
+ ),
+ )
+
+ val frecentSite = TopFrecentSiteInfo("https://getpocket.com", "Pocket")
+
+ val frecentFilteredSite = TopFrecentSiteInfo(filteredUrl, "testSearch")
+
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSite,
+ frecentFilteredSite,
+ ),
+ )
+
+ var topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 4,
+ frecencyConfig = frecencyConfig,
+ providerConfig = TopSitesProviderConfig(showProviderTopSites = true),
+ )
+
+ assertEquals(4, topSites.size)
+ assertTrue(topSites.contains(frecentSite.toTopSite()))
+ assertTrue(topSites.contains(providedFilteredSite))
+ assertTrue(topSites.contains(defaultSite))
+ assertTrue(topSites.contains(pinnedSite))
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+
+ topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 5,
+ frecencyConfig = frecencyConfig,
+ providerConfig = TopSitesProviderConfig(showProviderTopSites = true),
+ )
+
+ assertEquals(4, topSites.size)
+ assertTrue(topSites.contains(frecentSite.toTopSite()))
+ assertTrue(topSites.contains(providedFilteredSite))
+ assertTrue(topSites.contains(defaultSite))
+ assertTrue(topSites.contains(pinnedSite))
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+
+ @Test
+ fun `GIVEN frecent top sites host exist as a provided site WHEN top sites are retrieved THEN filters out frecent sites with host that already exist in provided sites`() = runTestOnMain {
+ val defaultTopSitesStorage = DefaultTopSitesStorage(
+ pinnedSitesStorage = pinnedSitesStorage,
+ historyStorage = historyStorage,
+ topSitesProvider = topSitesProvider,
+ defaultTopSites = listOf(),
+ coroutineContext = coroutineContext,
+ )
+
+ val defaultSiteFirefox = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+ val pinnedSite1 = TopSite.Pinned(
+ id = 2,
+ title = "Google",
+ url = "https://google.com",
+ createdAt = 2,
+ )
+ val providedSite1 = TopSite.Provided(
+ id = 3,
+ title = "Amazon",
+ url = "https://www.amazon.com/?tag=sponsored-shortcut",
+ clickUrl = "https://www.amazon.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val providedSite2 = TopSite.Provided(
+ id = 4,
+ title = "UnderArmour",
+ url = "https://www.underarmour.com/?tag=sponsored-shortcut",
+ clickUrl = "https://www.underarmour.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 4,
+ )
+
+ whenever(pinnedSitesStorage.getPinnedSites()).thenReturn(
+ listOf(
+ defaultSiteFirefox,
+ pinnedSite1,
+ ),
+ )
+ whenever(pinnedSitesStorage.getPinnedSitesCount()).thenReturn(2)
+ whenever(topSitesProvider.getTopSites()).thenReturn(
+ listOf(
+ providedSite1,
+ providedSite2,
+ ),
+ )
+
+ val frecentSite1 = TopFrecentSiteInfo("https://www.amazon.com", "Amazon")
+ val frecentSite2 = TopFrecentSiteInfo("https://www.amazon.com/Wireless-Charging-Station-Charger-AirPods/dp/B09KTY5GM7?pf_rd_r=NCJV8SPRQ2K43XM6WWKS&pf_rd_p=7b590888-dba4-4742-b2f2-7b20b1700e00&pd_rd_r=4fbaf1df-96be-470a-9811-0bc2aa8b415f&pd_rd_w=Viqqz&pd_rd_wg=9Emfa", "Amazon")
+ val frecentSite3 = TopFrecentSiteInfo("https://www.underarmour.com", "UnderArmour")
+ val frecentSite4 = TopFrecentSiteInfo("https://www.underarmour.com/en-us/p/curry_brand_shoes_and_gear/mens_curry_sour_then_sweet_crewneck/195253758836.html", "UnderArmour")
+ val frecentSite5 = TopFrecentSiteInfo("https://www.example.com", "Example")
+ val frecentSite6 = TopFrecentSiteInfo("https://www.getfirefox.com", "Firefox")
+
+ whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(
+ listOf(
+ frecentSite1,
+ frecentSite2,
+ frecentSite3,
+ frecentSite4,
+ frecentSite5,
+ frecentSite6,
+ ),
+ )
+
+ val topSites = defaultTopSitesStorage.getTopSites(
+ totalSites = 10,
+ frecencyConfig = TopSitesFrecencyConfig(
+ frecencyTresholdOption = FrecencyThresholdOption.NONE,
+ ),
+ providerConfig = TopSitesProviderConfig(
+ showProviderTopSites = true,
+ ),
+ )
+
+ verify(historyStorage).getTopFrecentSites(10, frecencyThreshold = FrecencyThresholdOption.NONE)
+
+ assertEquals(6, topSites.size)
+ assertEquals(providedSite1, topSites[0])
+ assertEquals(providedSite2, topSites[1])
+ assertFalse(topSites.contains(frecentSite1.toTopSite()))
+ assertFalse(topSites.contains(frecentSite2.toTopSite()))
+ assertFalse(topSites.contains(frecentSite3.toTopSite()))
+ assertFalse(topSites.contains(frecentSite4.toTopSite()))
+ assertEquals(defaultSiteFirefox, topSites[2])
+ assertEquals(pinnedSite1, topSites[3])
+ assertEquals(defaultTopSitesStorage.cachedTopSites, topSites)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/PinnedSitesStorageTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/PinnedSitesStorageTest.kt
new file mode 100644
index 0000000000..69c30de8d9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/PinnedSitesStorageTest.kt
@@ -0,0 +1,135 @@
+/* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.top.sites.db.PinnedSiteDao
+import mozilla.components.feature.top.sites.db.PinnedSiteEntity
+import mozilla.components.feature.top.sites.db.TopSiteDatabase
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+@ExperimentalCoroutinesApi // for runTest
+class PinnedSitesStorageTest {
+
+ @Test
+ fun addAllDefaultSites() = runTest {
+ val storage = PinnedSiteStorage(mock())
+ val dao = mockDao(storage)
+
+ storage.currentTimeMillis = { 42 }
+
+ storage.addAllPinnedSites(
+ listOf(
+ Pair("Mozilla", "https://www.mozilla.org"),
+ Pair("Firefox", "https://www.firefox.com"),
+ Pair("Wikipedia", "https://www.wikipedia.com"),
+ Pair("Pocket", "https://www.getpocket.com"),
+ ),
+ isDefault = true,
+ )
+
+ verify(dao).insertAllPinnedSites(
+ listOf(
+ PinnedSiteEntity(title = "Mozilla", url = "https://www.mozilla.org", isDefault = true, createdAt = 42),
+ PinnedSiteEntity(title = "Firefox", url = "https://www.firefox.com", isDefault = true, createdAt = 42),
+ PinnedSiteEntity(title = "Wikipedia", url = "https://www.wikipedia.com", isDefault = true, createdAt = 42),
+ PinnedSiteEntity(title = "Pocket", url = "https://www.getpocket.com", isDefault = true, createdAt = 42),
+ ),
+ )
+
+ Unit
+ }
+
+ @Test
+ fun addPinnedSite() = runTest {
+ val storage = PinnedSiteStorage(mock())
+ val dao = mockDao(storage)
+
+ storage.currentTimeMillis = { 3 }
+
+ storage.addPinnedSite("Mozilla", "https://www.mozilla.org")
+ storage.addPinnedSite("Firefox", "https://www.firefox.com", isDefault = true)
+
+ // PinnedSiteDao.insertPinnedSite is actually called with "id = null", but due to an
+ // extraneous assignment ("entity.id = ") in PinnedSiteStorage.addPinnedSite we can for now
+ // only verify the call with "id = 0". See issue #9708.
+ verify(dao).insertPinnedSite(PinnedSiteEntity(id = 0, title = "Mozilla", url = "https://www.mozilla.org", isDefault = false, createdAt = 3))
+ verify(dao).insertPinnedSite(PinnedSiteEntity(id = 0, title = "Firefox", url = "https://www.firefox.com", isDefault = true, createdAt = 3))
+
+ Unit
+ }
+
+ @Test
+ fun removePinnedSite() = runTest {
+ val storage = PinnedSiteStorage(mock())
+ val dao = mockDao(storage)
+
+ storage.removePinnedSite(TopSite.Pinned(1, "Mozilla", "https://www.mozilla.org", 1))
+ storage.removePinnedSite(TopSite.Default(2, "Firefox", "https://www.firefox.com", 1))
+
+ verify(dao).deletePinnedSite(PinnedSiteEntity(1, "Mozilla", "https://www.mozilla.org", false, 1))
+ verify(dao).deletePinnedSite(PinnedSiteEntity(2, "Firefox", "https://www.firefox.com", true, 1))
+ }
+
+ @Test
+ fun getPinnedSites() = runTest {
+ val storage = PinnedSiteStorage(mock())
+ val dao = mockDao(storage)
+
+ `when`(dao.getPinnedSites()).thenReturn(
+ listOf(
+ PinnedSiteEntity(1, "Mozilla", "https://www.mozilla.org", false, 10),
+ PinnedSiteEntity(2, "Firefox", "https://www.firefox.com", true, 10),
+ ),
+ )
+ `when`(dao.getPinnedSitesCount()).thenReturn(2)
+
+ val topSites = storage.getPinnedSites()
+ val topSitesCount = storage.getPinnedSitesCount()
+
+ assertNotNull(topSites)
+ assertEquals(2, topSites.size)
+ assertEquals(2, topSitesCount)
+
+ with(topSites[0]) {
+ assertEquals(1L, id)
+ assertEquals("Mozilla", title)
+ assertEquals("https://www.mozilla.org", url)
+ assertEquals(10L, createdAt)
+ }
+
+ with(topSites[1]) {
+ assertEquals(2L, id)
+ assertEquals("Firefox", title)
+ assertEquals("https://www.firefox.com", url)
+ assertEquals(10L, createdAt)
+ }
+ }
+
+ @Test
+ fun updatePinnedSite() = runTest {
+ val storage = PinnedSiteStorage(mock())
+ val dao = mockDao(storage)
+
+ val site = TopSite.Pinned(1, "Mozilla", "https://www.mozilla.org", 1)
+ storage.updatePinnedSite(site, "Mozilla (IT)", "https://www.mozilla.org/it")
+
+ verify(dao).updatePinnedSite(PinnedSiteEntity(1, "Mozilla (IT)", "https://www.mozilla.org/it", false, 1))
+ }
+
+ private fun mockDao(storage: PinnedSiteStorage): PinnedSiteDao {
+ val db = mock<TopSiteDatabase>()
+ storage.database = lazy { db }
+ val dao = mock<PinnedSiteDao>()
+ `when`(db.pinnedSiteDao()).thenReturn(dao)
+ return dao
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesFeatureTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesFeatureTest.kt
new file mode 100644
index 0000000000..21d7274e7c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesFeatureTest.kt
@@ -0,0 +1,34 @@
+/* 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 mozilla.components.feature.top.sites.presenter.TopSitesPresenter
+import mozilla.components.feature.top.sites.view.TopSitesView
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class TopSitesFeatureTest {
+
+ private val view: TopSitesView = mock()
+ private val storage: TopSitesStorage = mock()
+ private val presenter: TopSitesPresenter = mock()
+ private val config: () -> TopSitesConfig = mock()
+ private val feature: TopSitesFeature = TopSitesFeature(view, storage, config, presenter)
+
+ @Test
+ fun start() {
+ feature.start()
+
+ verify(presenter).start()
+ }
+
+ @Test
+ fun stop() {
+ feature.stop()
+
+ verify(presenter).stop()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesUseCasesTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesUseCasesTest.kt
new file mode 100644
index 0000000000..6c100cdb57
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/TopSitesUseCasesTest.kt
@@ -0,0 +1,65 @@
+/* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+@ExperimentalCoroutinesApi // for runTest
+
+class TopSitesUseCasesTest {
+
+ @Test
+ fun `AddPinnedSiteUseCase`() = runTest {
+ val topSitesStorage: TopSitesStorage = mock()
+ val useCases = TopSitesUseCases(topSitesStorage)
+
+ useCases.addPinnedSites("Mozilla", "https://www.mozilla.org", isDefault = true)
+ verify(topSitesStorage).addTopSite(
+ "Mozilla",
+ "https://www.mozilla.org",
+ isDefault = true,
+ )
+ }
+
+ @Test
+ fun `RemoveTopSiteUseCase`() = runTest {
+ val topSitesStorage: TopSitesStorage = mock()
+ val topSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+
+ val useCases = TopSitesUseCases(topSitesStorage)
+
+ useCases.removeTopSites(topSite)
+
+ verify(topSitesStorage).removeTopSite(topSite)
+ }
+
+ @Test
+ fun `UpdateTopSiteUseCase`() = runTest {
+ val topSitesStorage: TopSitesStorage = mock()
+ val topSite = TopSite.Default(
+ id = 1,
+ title = "Firefox",
+ url = "https://firefox.com",
+ createdAt = 1,
+ )
+
+ val useCases = TopSitesUseCases(topSitesStorage)
+
+ val title = "New title"
+ val url = "https://www.example.com/new-url"
+ useCases.updateTopSites(topSite, title, url)
+
+ verify(topSitesStorage).updateTopSite(topSite, title, url)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/ext/TopSiteTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/ext/TopSiteTest.kt
new file mode 100644
index 0000000000..210c6e0b09
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/ext/TopSiteTest.kt
@@ -0,0 +1,108 @@
+/* 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.ext
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TopSiteTest {
+
+ @Test
+ fun hasUrl() {
+ val topSites = listOf(
+ TopSite.Frecent(
+ id = 1,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ createdAt = 1,
+ ),
+ )
+
+ assertTrue(topSites.hasUrl("https://mozilla.com"))
+ assertTrue(topSites.hasUrl("https://www.mozilla.com"))
+ assertTrue(topSites.hasUrl("http://mozilla.com"))
+ assertTrue(topSites.hasUrl("http://www.mozilla.com"))
+ assertTrue(topSites.hasUrl("mozilla.com"))
+ assertTrue(topSites.hasUrl("https://mozilla.com/"))
+
+ assertFalse(topSites.hasUrl("https://m.mozilla.com"))
+ assertFalse(topSites.hasUrl("https://mozilla.com/path"))
+ assertFalse(topSites.hasUrl("https://firefox.com"))
+ assertFalse(topSites.hasUrl("https://mozilla.com/path/is/long"))
+ assertFalse(topSites.hasUrl("https://mozilla.com/path#anchor"))
+ }
+
+ @Test
+ fun hasHostOneItem() {
+ val topSites = listOf(
+ TopSite.Frecent(
+ id = 1,
+ title = "Amazon",
+ url = "https://amazon.com/playstation",
+ createdAt = 1,
+ ),
+ )
+
+ assertTrue(topSites.hasHost("https://amazon.com"))
+ assertTrue(topSites.hasHost("https://www.amazon.com"))
+ assertTrue(topSites.hasHost("http://amazon.com"))
+ assertTrue(topSites.hasHost("http://www.amazon.com"))
+ assertTrue(topSites.hasHost("amazon.com"))
+ assertTrue(topSites.hasHost("https://amazon.com/"))
+ assertTrue(topSites.hasHost("HTTPS://AMAZON.COM/"))
+ assertFalse(topSites.hasHost("https://amzn.com/"))
+ assertFalse(topSites.hasHost("https://aws.amazon.com/"))
+ assertFalse(topSites.hasHost("https://youtube.com/"))
+ }
+
+ @Test
+ fun hasHostNoItem() {
+ val topSites = emptyList<TopSite.Frecent>()
+
+ assertFalse(topSites.hasHost("https://amazon.com"))
+ assertFalse(topSites.hasHost("https://www.amazon.com"))
+ assertFalse(topSites.hasHost("http://amazon.com"))
+ assertFalse(topSites.hasHost("http://www.amazon.com"))
+ assertFalse(topSites.hasHost("amazon.com"))
+ assertFalse(topSites.hasHost("https://amazon.com/"))
+ assertFalse(topSites.hasHost("HTTPS://AMAZON.COM/"))
+ assertFalse(topSites.hasHost("https://amzn.com/"))
+ assertFalse(topSites.hasHost("https://aws.amazon.com/"))
+ }
+
+ @Test
+ fun hasHostMultipleItems() {
+ val topSites = listOf(
+ TopSite.Frecent(
+ id = 1,
+ title = "Amazon",
+ url = "https://amazon.com/playstation",
+ createdAt = 1,
+ ),
+ TopSite.Frecent(
+ id = 2,
+ title = "Hotels",
+ url = "https://www.hotels.com/",
+ createdAt = 2,
+ ),
+ TopSite.Frecent(
+ id = 3,
+ title = "eBay",
+ url = "https://www.ebay.com/n/all-categories",
+ createdAt = 3,
+ ),
+ )
+
+ assertTrue(topSites.hasHost("https://amazon.com"))
+ assertTrue(topSites.hasHost("https://hotels.com"))
+ assertTrue(topSites.hasHost("http://ebay.com"))
+ assertFalse(topSites.hasHost("http://google.com"))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/fact/TopSitesFactsTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/fact/TopSitesFactsTest.kt
new file mode 100644
index 0000000000..a8ab8eded8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/fact/TopSitesFactsTest.kt
@@ -0,0 +1,44 @@
+/* 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.fact
+
+import mozilla.components.feature.top.sites.facts.TopSitesFacts
+import mozilla.components.feature.top.sites.facts.emitTopSitesCountFact
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TopSitesFactsTest {
+
+ @Test
+ fun `Emits facts for current state`() {
+ CollectionProcessor.withFactCollection { facts ->
+
+ assertEquals(0, facts.size)
+
+ emitTopSitesCountFact(5)
+
+ assertEquals(1, facts.size)
+ facts[0].apply {
+ assertEquals(Component.FEATURE_TOP_SITES, component)
+ assertEquals(Action.INTERACTION, action)
+ assertEquals(TopSitesFacts.Items.COUNT, item)
+ assertEquals(5, value?.toInt())
+ }
+
+ emitTopSitesCountFact(1)
+
+ assertEquals(2, facts.size)
+ facts[1].apply {
+ assertEquals(Component.FEATURE_TOP_SITES, component)
+ assertEquals(Action.INTERACTION, action)
+ assertEquals(TopSitesFacts.Items.COUNT, item)
+ assertEquals(1, value?.toInt())
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenterTest.kt b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenterTest.kt
new file mode 100644
index 0000000000..eb4a14ac24
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/java/mozilla/components/feature/top/sites/presenter/DefaultTopSitesPresenterTest.kt
@@ -0,0 +1,37 @@
+/* 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.presenter
+
+import mozilla.components.feature.top.sites.DefaultTopSitesStorage
+import mozilla.components.feature.top.sites.TopSitesConfig
+import mozilla.components.feature.top.sites.view.TopSitesView
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+class DefaultTopSitesPresenterTest {
+
+ private val view: TopSitesView = mock()
+ private val storage: DefaultTopSitesStorage = mock()
+ private val config: () -> TopSitesConfig = mock()
+ private val presenter: DefaultTopSitesPresenter =
+ spy(DefaultTopSitesPresenter(view, storage, config))
+
+ @Test
+ fun start() {
+ presenter.start()
+
+ verify(presenter).onStorageUpdated()
+ verify(storage).register(presenter)
+ }
+
+ @Test
+ fun stop() {
+ presenter.stop()
+
+ verify(storage).unregister(presenter)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/top-sites/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/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)
diff --git a/mobile/android/android-components/components/feature/top-sites/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/top-sites/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/top-sites/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28