Commit c78e6a71 authored by Gabriel Luong's avatar Gabriel Luong
Browse files

Issue #7978: Part 4 - Implement TopSitesFeature

parent dc7ee797
......@@ -43,7 +43,14 @@ android {
}
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions.freeCompilerArgs += [
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi"
]
}
dependencies {
implementation project(':browser-storage-sync')
implementation project(':support-ktx')
implementation project(':support-base')
......@@ -59,8 +66,10 @@ dependencies {
testImplementation project(':support-test')
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_junit
testImplementation Dependencies.testing_mockito
testImplementation Dependencies.testing_coroutines
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.kotlin_coroutines
......
/* 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.toTopSite
import mozilla.components.feature.top.sites.TopSite.Type.FRECENT
import mozilla.components.feature.top.sites.ext.hasUrl
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 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 defaultTopSites: List<Pair<String, String>> = listOf(),
coroutineContext: CoroutineContext = Dispatchers.IO
) : TopSitesStorage, Observable<TopSitesStorage.Observer> by ObserverRegistry() {
private var scope = CoroutineScope(coroutineContext)
// Cache of the last retrieved top sites
var cachedTopSites = listOf<TopSite>()
init {
if (defaultTopSites.isNotEmpty()) {
scope.launch {
defaultTopSites.forEach { (title, url) ->
addPinnedSite(title, url, isDefault = true)
}
}
}
}
override fun addPinnedSite(title: String, url: String, isDefault: Boolean) {
scope.launch {
pinnedSitesStorage.addPinnedSite(title, url, isDefault)
notifyObservers { onStorageUpdated() }
}
}
override fun removeTopSite(topSite: TopSite) {
scope.launch {
if (topSite.type == FRECENT) {
historyStorage.deleteVisitsFor(topSite.url)
notifyObservers { onStorageUpdated() }
} else {
pinnedSitesStorage.removePinnedSite(topSite)
notifyObservers { onStorageUpdated() }
}
}
}
override suspend fun getTopSites(
totalSites: Int,
includeFrecent: Boolean
): List<TopSite> {
val topSites = ArrayList<TopSite>()
val pinnedSites = pinnedSitesStorage.getPinnedSites().take(totalSites)
val numSitesRequired = totalSites - pinnedSites.size
topSites.addAll(pinnedSites)
if (includeFrecent && numSitesRequired > 0) {
// Get twice the required size to buffer for duplicate entries with
// existing pinned sites
val frecentSites = historyStorage
.getTopFrecentSites(numSitesRequired * 2)
.map { it.toTopSite() }
.filter { !pinnedSites.hasUrl(it.url) }
.take(numSitesRequired)
topSites.addAll(frecentSites)
}
cachedTopSites = topSites
return topSites
}
}
......@@ -5,62 +5,51 @@
package mozilla.components.feature.top.sites
import android.content.Context
import androidx.paging.DataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mozilla.components.feature.top.sites.adapter.PinnedSiteAdapter
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import mozilla.components.feature.top.sites.db.TopSiteDatabase
import mozilla.components.feature.top.sites.db.PinnedSiteEntity
import mozilla.components.feature.top.sites.db.toPinnedSite
/**
* A storage implementation for organizing pinned sites.
*/
class PinnedSiteStorage(
context: Context
) {
class PinnedSiteStorage(context: Context) {
internal var database: Lazy<TopSiteDatabase> = lazy { TopSiteDatabase.get(context) }
private val pinnedSiteDao by lazy { database.value.pinnedSiteDao() }
/**
* Adds a new [PinnedSite].
* 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.
*/
fun addPinnedSite(title: String, url: String, isDefault: Boolean = false) {
PinnedSiteEntity(
suspend fun addPinnedSite(title: String, url: String, isDefault: Boolean = false) = withContext(IO) {
val entity = PinnedSiteEntity(
title = title,
url = url,
isDefault = isDefault,
createdAt = System.currentTimeMillis()
).also { entity ->
entity.id = database.value.pinnedSiteDao().insertPinnedSite(entity)
}
)
entity.id = pinnedSiteDao.insertPinnedSite(entity)
}
/**
* Returns a [Flow] list of all the [PinnedSite] instances.
* Returns a list of all the pinned sites.
*/
fun getPinnedSites(): Flow<List<PinnedSite>> {
return database.value.pinnedSiteDao().getPinnedSites().map { list ->
list.map { entity -> PinnedSiteAdapter(entity) }
}
suspend fun getPinnedSites(): List<TopSite> = withContext(IO) {
pinnedSiteDao.getPinnedSites().map { entity -> entity.toTopSite() }
}
/**
* Returns all [PinnedSite]s as a [DataSource.Factory].
*/
fun getPinnedSitesPaged(): DataSource.Factory<Int, PinnedSite> = database.value
.pinnedSiteDao()
.getPinnedSitesPaged()
.map { entity -> PinnedSiteAdapter(entity) }
/**
* Removes the given [PinnedSite].
* Removes the given pinned site.
*
* @param site The pinned site.
*/
fun removePinnedSite(site: PinnedSite) {
val pinnedSiteEntity = (site as PinnedSiteAdapter).entity
database.value.pinnedSiteDao().deletePinnedSite(pinnedSiteEntity)
suspend fun removePinnedSite(site: TopSite) = withContext(IO) {
pinnedSiteDao.deletePinnedSite(site.toPinnedSite())
}
}
/* 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.
*
* @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 of a top site.
*/
data class TopSite(
val id: Long?,
val title: String?,
val url: String,
val createdAt: Long?,
val type: Type
) {
/**
* The type of a [TopSite].
*/
enum class Type {
/**
* This top site was added as a default by the application.
*/
DEFAULT,
/**
* This top site was pinned by an user.
*/
PINNED,
/**
* This top site is auto-generated from the history storage based on the most frecent site.
*/
FRECENT
}
}
......@@ -5,26 +5,13 @@
package mozilla.components.feature.top.sites
/**
* A pinned site.
* 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 includeFrecent If true, includes frecent top site results.
*/
interface PinnedSite {
/**
* Unique ID of this pinned site.
*/
val id: Long
/**
* The title of the pinned site.
*/
val title: String
/**
* The URL of the pinned site.
*/
val url: String
/**
* Whether or not the pinned site is a default pinned site (added as a default by the application).
*/
val isDefault: Boolean
}
data class TopSitesConfig(
val totalSites: Int,
val includeFrecent: Boolean
)
/* 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()
}
}
/* 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.support.base.observer.Observable
/**
* Abstraction layer above the [PinnedSiteStorage] and [PlacesHistoryStorage] storages.
*/
interface TopSitesStorage : Observable<TopSitesStorage.Observer> {
/**
* 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.
*/
fun addPinnedSite(title: String, url: String, isDefault: Boolean = false)
/**
* Removes the given [TopSite].
*
* @param topSite The top site.
*/
fun removeTopSite(topSite: TopSite)
/**
* Return a unified list of top sites based on the given number of sites desired.
* If `includeFrecent` is true, 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 includeFrecent If true, includes frecent top site results.
*/
suspend fun getTopSites(totalSites: Int, includeFrecent: Boolean): 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()
}
}
/* 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.addPinnedSite(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)
}
}
val addPinnedSites: AddPinnedSiteUseCase by lazy {
AddPinnedSiteUseCase(topSitesStorage)
}
val removeTopSites: RemoveTopSiteUseCase by lazy {
RemoveTopSiteUseCase(topSitesStorage)
}
}
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.top.sites.adapter
import mozilla.components.feature.top.sites.PinnedSite
import mozilla.components.feature.top.sites.db.PinnedSiteEntity
internal class PinnedSiteAdapter(
internal val entity: PinnedSiteEntity
) : PinnedSite {
override val id: Long
get() = entity.id!!
override val title: String
get() = entity.title
override val url: String
get() = entity.url
override val isDefault: Boolean
get() = entity.isDefault
override fun equals(other: Any?): Boolean {
if (other !is PinnedSiteAdapter) {
return false
}
return entity == other.entity
}
override fun hashCode(): Int {
return entity.hashCode()
}
}
......@@ -4,30 +4,26 @@
package mozilla.components.feature.top.sites.db
import androidx.paging.DataSource
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 kotlinx.coroutines.flow.Flow
/**
* Internal DAO for accessing [PinnedSiteEntity] instances.
*/
@Dao
internal interface PinnedSiteDao {
@WorkerThread
@Insert
fun insertPinnedSite(site: PinnedSiteEntity): Long
@WorkerThread
@Delete
fun deletePinnedSite(site: PinnedSiteEntity)
@Transaction
@WorkerThread
@Query("SELECT * FROM top_sites")
fun getPinnedSites(): Flow<List<PinnedSiteEntity>>
@Transaction
@Query("SELECT * FROM top_sites")
fun getPinnedSitesPaged(): DataSource.Factory<Int, PinnedSiteEntity>
fun getPinnedSites(): List<PinnedSiteEntity>
}
......@@ -7,6 +7,9 @@ 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
import mozilla.components.feature.top.sites.TopSite.Type.DEFAULT
import mozilla.components.feature.top.sites.TopSite.Type.PINNED
/**
* Internal entity representing a pinned site.
......@@ -28,4 +31,25 @@ internal data class PinnedSiteEntity(
@ColumnInfo(name = "created_at")
var createdAt: Long = System.currentTimeMillis()
)
) {
internal fun toTopSite(): TopSite {
val type = if (isDefault) DEFAULT else PINNED
return TopSite(
id,
title,
url,
createdAt,
type
)
}
}
internal fun TopSite.toPinnedSite(): PinnedSiteEntity {
return PinnedSiteEntity(
id = id,
title = title ?: "",
url = url,
isDefault = type === DEFAULT,
createdAt = createdAt!!
)
}
/* 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.feature.top.sites.TopSite.Type.FRECENT
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
/**
* Returns a [TopSite] for the given [TopFrecentSiteInfo].
*/
fun TopFrecentSiteInfo.toTopSite(): TopSite {
return TopSite(
id = null,
title = this.title?.takeIf(String::isNotBlank) ?: this.url.tryGetHostFromUrl(),
url = this.url,
createdAt = null,
type = FRECENT
)
}
/* 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
/**
* Returns true if the given url is in the list