Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Issue #11698: Add an updater to periodically fetch the Contile Top Sites
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielluong committed Feb 17, 2022
1 parent 134f6a0 commit ab606d4
Show file tree
Hide file tree
Showing 12 changed files with 480 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package mozilla.components.feature.top.sites

import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -20,15 +21,17 @@ 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
* @property context A reference to the application context.
* @property pinnedSitesStorage An instance of [PinnedSiteStorage], used for storing pinned sites.
* @property historyStorage An instance of [PlacesHistoryStorage], used for retrieving top frecent
* sites from history.
* @param topSitesProvider An optional instance of [TopSitesProvider], used for retrieving
* @property 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
* @property defaultTopSites A list containing a title to url pair of default top sites to be added
* to the [PinnedSiteStorage].
*/
class DefaultTopSitesStorage(
private val context: Context,
private val pinnedSitesStorage: PinnedSiteStorage,
private val historyStorage: PlacesHistoryStorage,
private val topSitesProvider: TopSitesProvider? = null,
Expand Down Expand Up @@ -100,7 +103,7 @@ class DefaultTopSitesStorage(
) {
try {
val providerTopSites = topSitesProvider
.getTopSites(allowCache = true)
.getTopSites(context, allowCache = true)
.take(numSitesRequired)
topSites.addAll(providerTopSites)
numSitesRequired -= providerTopSites.size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package mozilla.components.feature.top.sites

import android.content.Context

/**
* A contract that indicates how a top sites provider must behave.
*/
Expand All @@ -16,5 +18,5 @@ interface TopSitesProvider {
* cached response.
* @return a list of top sites from the provider.
*/
suspend fun getTopSites(allowCache: Boolean = true): List<TopSite>
suspend fun getTopSites(context: Context, allowCache: Boolean = true): List<TopSite>
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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.robolectric.testContext
import mozilla.components.support.test.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
Expand All @@ -38,6 +39,7 @@ class DefaultTopSitesStorageTest {
)

DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = defaultTopSites,
Expand All @@ -50,6 +52,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `addPinnedSite`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand All @@ -68,6 +71,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `removeTopSite`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand Down Expand Up @@ -113,6 +117,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `updateTopSite`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand Down Expand Up @@ -156,6 +161,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `GIVEN frecencyConfig and providerConfig are null WHEN getTopSites is called THEN only default and pinned sites are returned`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand Down Expand Up @@ -212,6 +218,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `GIVEN providerConfig is specified WHEN getTopSites is called THEN default, pinned and provided top sites are returned`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
topSitesProvider = topSitesProvider,
Expand Down Expand Up @@ -247,7 +254,7 @@ class DefaultTopSitesStorageTest {
pinnedSite
)
)
whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite))
whenever(topSitesProvider.getTopSites(testContext)).thenReturn(listOf(providedSite))

var topSites = defaultTopSitesStorage.getTopSites(totalSites = 0)

Expand Down Expand Up @@ -333,6 +340,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `GIVEN frecencyConfig and providerConfig are specified WHEN getTopSites is called THEN default, pinned, provided and frecent top sites are returned`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
topSitesProvider = topSitesProvider,
Expand Down Expand Up @@ -368,7 +376,7 @@ class DefaultTopSitesStorageTest {
pinnedSite
)
)
whenever(topSitesProvider.getTopSites()).thenReturn(listOf(providedSite))
whenever(topSitesProvider.getTopSites(testContext)).thenReturn(listOf(providedSite))

val frecentSite1 = TopFrecentSiteInfo("https://mozilla.com", "Mozilla")
whenever(historyStorage.getTopFrecentSites(anyInt(), any())).thenReturn(listOf(frecentSite1))
Expand Down Expand Up @@ -441,6 +449,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `getTopSites returns pinned and frecent sites when frecencyConfig is specified`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand Down Expand Up @@ -558,6 +567,7 @@ class DefaultTopSitesStorageTest {
@Test
fun `getTopSites filters out frecent sites that already exist in pinned sites`() = runBlockingTest {
val defaultTopSitesStorage = DefaultTopSitesStorage(
context = testContext,
pinnedSitesStorage = pinnedSitesStorage,
historyStorage = historyStorage,
defaultTopSites = listOf(),
Expand Down
2 changes: 2 additions & 0 deletions components/service/contile/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ android {
dependencies {
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
implementation Dependencies.androidx_work_runtime

implementation project(':concept-fetch')
implementation project(':support-ktx')
Expand All @@ -32,6 +33,7 @@ dependencies {

testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.androidx_work_testing
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,14 @@ internal const val CACHE_FILE_NAME = "mozilla_components_service_contile.json"
internal const val MINUTE_IN_MS = 60 * 1000

/**
* Provide access to the Contile services API.
* Provides access to the Contile services API.
*
* @property context A reference to the application context.
* @property client [Client] used for interacting with the Contile HTTP API.
* @property endPointURL The url of the endpoint to fetch from. Defaults to [CONTILE_ENDPOINT_URL].
* @property maxCacheAgeInMinutes Maximum time (in minutes) the cache should remain valid
* before a refresh is attempted. Defaults to -1, meaning no cache is being used by default.
*/
class ContileTopSitesProvider(
private val context: Context,
private val client: Client,
private val endPointURL: String = CONTILE_ENDPOINT_URL,
private val maxCacheAgeInMinutes: Long = -1
Expand All @@ -51,15 +49,16 @@ class ContileTopSitesProvider(
* Returns a cached response if [allowCache] is true and the cache is not expired
* (@see [maxCacheAgeInMinutes]).
*
* @param context A reference to the application context.
* @param allowCache Whether or not the result may be provided from a previously cached
* response. Note that a [maxCacheAgeInMinutes] must be provided in order for the cache to be
* active.
* @throws IOException if the request failed to fetch any top sites.
*/
@Throws(IOException::class)
override suspend fun getTopSites(allowCache: Boolean): List<TopSite.Provided> {
val cachedTopSites = if (allowCache && !isCacheExpired()) {
readFromDiskCache()
override suspend fun getTopSites(context: Context, allowCache: Boolean): List<TopSite.Provided> {
val cachedTopSites = if (allowCache && !isCacheExpired(context)) {
readFromDiskCache(context)
} else {
null
}
Expand All @@ -69,14 +68,14 @@ class ContileTopSitesProvider(
}

return try {
fetchTopSites()
fetchTopSites(context)
} catch (e: IOException) {
logger.error("Failed to fetch contile top sites", e)
throw e
}
}

private fun fetchTopSites(): List<TopSite.Provided> {
private fun fetchTopSites(context: Context): List<TopSite.Provided> {
client.fetch(
Request(url = endPointURL)
).use { response ->
Expand All @@ -86,7 +85,7 @@ class ContileTopSitesProvider(
return try {
JSONObject(responseBody).getTopSites().also {
if (maxCacheAgeInMinutes > 0) {
writeToDiskCache(responseBody)
writeToDiskCache(context, responseBody)
}
}
} catch (e: JSONException) {
Expand All @@ -102,35 +101,35 @@ class ContileTopSitesProvider(
}

@VisibleForTesting
internal fun readFromDiskCache(): List<TopSite.Provided>? {
internal fun readFromDiskCache(context: Context): List<TopSite.Provided>? {
synchronized(diskCacheLock) {
return getCacheFile().readAndDeserialize {
return getCacheFile(context).readAndDeserialize {
JSONObject(it).getTopSites()
}
}
}

@VisibleForTesting
internal fun writeToDiskCache(responseBody: String) {
internal fun writeToDiskCache(context: Context, responseBody: String) {
synchronized(diskCacheLock) {
getCacheFile().writeString { responseBody }
getCacheFile(context).writeString { responseBody }
}
}

@VisibleForTesting
internal fun isCacheExpired() =
getCacheLastModified() < Date().time - maxCacheAgeInMinutes * MINUTE_IN_MS
internal fun isCacheExpired(context: Context) =
getCacheLastModified(context) < Date().time - maxCacheAgeInMinutes * MINUTE_IN_MS

@VisibleForTesting
internal fun getCacheLastModified(): Long {
val file = getBaseCacheFile()
internal fun getCacheLastModified(context: Context): Long {
val file = getBaseCacheFile(context)
return if (file.exists()) file.lastModified() else -1
}

private fun getCacheFile(): AtomicFile = AtomicFile(getBaseCacheFile())
private fun getCacheFile(context: Context): AtomicFile = AtomicFile(getBaseCacheFile(context))

@VisibleForTesting
internal fun getBaseCacheFile(): File = File(context.filesDir, CACHE_FILE_NAME)
internal fun getBaseCacheFile(context: Context): File = File(context.filesDir, CACHE_FILE_NAME)
}

internal fun JSONObject.getTopSites(): List<TopSite.Provided> =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* 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.service.contile

import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import mozilla.components.support.base.log.logger.Logger
import java.util.concurrent.TimeUnit

/**
* Provides functionality to schedule updates of Contile top sites.
*
* @property context A reference to the application context.
* @property provider An instance of [ContileTopSitesProvider] which provides access to the Contile
* services API for fetching top sites.
* @property frequency Optional [Frequency] that specifies how often the Contile top site updates
* should happen.
*/
class ContileTopSitesUpdater(
private val context: Context,
private val provider: ContileTopSitesProvider,
private val frequency: Frequency = Frequency(1, TimeUnit.DAYS)
) {

private val logger = Logger("ContileTopSitesUpdater")

/**
* Starts a work request in the background to periodically update the list of
* Contile top sites.
*/
fun startPeriodicWork() {
ContileTopSitesUseCases.initialize(provider)

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
createPeriodicWorkRequest()
)

logger.info("Started periodic work to update Contile top sites")
}

/**
* Stops the work request to periodically update the list of Contile top sites.
*/
fun stopPeriodicWork() {
ContileTopSitesUseCases.destroy()

WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK_TAG)

logger.info("Stopped periodic work to update Contile top sites")
}

@VisibleForTesting
internal fun createPeriodicWorkRequest() =
PeriodicWorkRequestBuilder<ContileTopSitesUpdaterWorker>(
repeatInterval = frequency.repeatInterval,
repeatIntervalTimeUnit = frequency.repeatIntervalTimeUnit
).apply {
setConstraints(getWorkerConstraints())
addTag(PERIODIC_WORK_TAG)
}.build()

@VisibleForTesting
internal fun getWorkerConstraints() = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

companion object {
internal const val PERIODIC_WORK_TAG = "mozilla.components.service.contile.periodicWork"
}
}

/**
* Indicates how often Contile top sites should be updated.
*
* @property repeatInterval Long indicating how often the update should happen.
* @property repeatIntervalTimeUnit The time unit of the [repeatInterval].
*/
data class Frequency(
val repeatInterval: Long,
val repeatIntervalTimeUnit: TimeUnit
)
Loading

0 comments on commit ab606d4

Please sign in to comment.