diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/feature/top-sites | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-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')
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 |