From 300df4873fdaf3c672e068e125da4da1b7ea38d7 Mon Sep 17 00:00:00 2001 From: Gabriel Luong Date: Fri, 11 Feb 2022 10:07:11 -0500 Subject: [PATCH] Issue #11698: Add an updater to periodically fetch the Contile Top Sites --- components/service/contile/build.gradle | 2 + .../contile/ContileTopSitesProvider.kt | 8 +- .../service/contile/ContileTopSitesUpdater.kt | 91 ++++++++++++++++++ .../contile/ContileTopSitesUpdaterWorker.kt | 34 +++++++ .../contile/ContileTopSitesUseCases.kt | 58 +++++++++++ .../contile/ContileTopSitesUpdaterTest.kt | 95 +++++++++++++++++++ .../ContileTopSitesUpdaterWorkerTest.kt | 67 +++++++++++++ .../contile/ContileTopSitesUseCasesTest.kt | 49 ++++++++++ 8 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt create mode 100644 components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.kt create mode 100644 components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.kt create mode 100644 components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt create mode 100644 components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt create mode 100644 components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt diff --git a/components/service/contile/build.gradle b/components/service/contile/build.gradle index fdbd3746a16..7c8d3c4af74 100644 --- a/components/service/contile/build.gradle +++ b/components/service/contile/build.gradle @@ -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') @@ -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 diff --git a/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt index 53b282f67c4..2e9e071b0d9 100644 --- a/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt +++ b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt @@ -27,7 +27,7 @@ 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. @@ -36,14 +36,14 @@ internal const val MINUTE_IN_MS = 60 * 1000 * before a refresh is attempted. Defaults to -1, meaning no cache is being used by default. */ class ContileTopSitesProvider( - private val context: Context, + context: Context, private val client: Client, private val endPointURL: String = CONTILE_ENDPOINT_URL, private val maxCacheAgeInMinutes: Long = -1 ) : TopSitesProvider { + private val applicationContext = context.applicationContext private val logger = Logger("ContileTopSitesProvider") - private val diskCacheLock = Any() /** @@ -130,7 +130,7 @@ class ContileTopSitesProvider( private fun getCacheFile(): AtomicFile = AtomicFile(getBaseCacheFile()) @VisibleForTesting - internal fun getBaseCacheFile(): File = File(context.filesDir, CACHE_FILE_NAME) + internal fun getBaseCacheFile(): File = File(applicationContext.filesDir, CACHE_FILE_NAME) } internal fun JSONObject.getTopSites(): List = diff --git a/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt new file mode 100644 index 00000000000..ec9f39f317f --- /dev/null +++ b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt @@ -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( + 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 +) diff --git a/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.kt b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.kt new file mode 100644 index 00000000000..0ae2c16fa76 --- /dev/null +++ b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.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.service.contile + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.support.base.log.logger.Logger + +/** + * An implementation of [CoroutineWorker] to perform Contile top site updates. + */ +internal class ContileTopSitesUpdaterWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + private val logger = Logger("ContileTopSitesUpdaterWorker") + + @Suppress("TooGenericExceptionCaught") + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + ContileTopSitesUseCases().refreshContileTopSites.invoke() + Result.success() + } catch (e: Exception) { + logger.error("Failed to refresh Contile top sites", e) + Result.failure() + } + } +} diff --git a/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.kt b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.kt new file mode 100644 index 00000000000..c3fcaa5077a --- /dev/null +++ b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.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.service.contile + +import androidx.annotation.VisibleForTesting + +/** + * Contains use cases related to the Contlie top sites feature. + */ +internal class ContileTopSitesUseCases { + + /** + * Refresh Contile top sites use case. + */ + class RefreshContileTopSitesUseCase internal constructor() { + /** + * Refreshes the Contile top sites. + */ + suspend operator fun invoke() { + requireContileTopSitesProvider().getTopSites(allowCache = false) + } + } + + internal companion object { + @VisibleForTesting internal var provider: ContileTopSitesProvider? = null + + /** + * Initializes the [ContileTopSitesProvider] which will providde access to the Contile + * services API. + */ + internal fun initialize(provider: ContileTopSitesProvider) { + this.provider = provider + } + + /** + * Unbinds the [ContileTopSitesProvider]. + */ + internal fun destroy() { + this.provider = null + } + + /** + * Returns the [ContileTopSitesProvider], otherwise throw an exception if the [provider] + * has not been initialized. + */ + internal fun requireContileTopSitesProvider(): ContileTopSitesProvider { + return requireNotNull(provider) { + "initialize must be called before trying to access the ContileTopSitesProvider" + } + } + } + + val refreshContileTopSites: RefreshContileTopSitesUseCase by lazy { + RefreshContileTopSitesUseCase() + } +} diff --git a/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt new file mode 100644 index 00000000000..de3fb5c745b --- /dev/null +++ b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.contile + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.testing.WorkManagerTestInitHelper +import kotlinx.coroutines.runBlocking +import mozilla.components.service.contile.ContileTopSitesUpdater.Companion.PERIODIC_WORK_TAG +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ContileTopSitesUpdaterTest { + + @Before + fun setUp() { + WorkManagerTestInitHelper.initializeTestWorkManager( + testContext, + Configuration.Builder().build() + ) + } + + @After + fun tearDown() { + WorkManager.getInstance(testContext).cancelUniqueWork(PERIODIC_WORK_TAG) + } + + @Test + fun `WHEN periodic work is started THEN work is queued`() = runBlocking { + val updater = ContileTopSitesUpdater(testContext, provider = mock()) + val workManager = WorkManager.getInstance(testContext) + var workInfo = workManager.getWorkInfosForUniqueWork(PERIODIC_WORK_TAG).await() + + assertTrue(workInfo.isEmpty()) + assertNull(ContileTopSitesUseCases.provider) + + updater.startPeriodicWork() + + assertNotNull(ContileTopSitesUseCases.provider) + assertNotNull(ContileTopSitesUseCases.requireContileTopSitesProvider()) + + workInfo = workManager.getWorkInfosForUniqueWork(PERIODIC_WORK_TAG).await() + val work = workInfo.first() + + assertEquals(1, workInfo.size) + assertEquals(WorkInfo.State.ENQUEUED, work.state) + assertTrue(work.tags.contains(PERIODIC_WORK_TAG)) + } + + @Test + fun `GIVEN periodic work is started WHEN period work is stopped THEN no work is queued`() = runBlocking { + val updater = ContileTopSitesUpdater(testContext, provider = mock()) + val workManager = WorkManager.getInstance(testContext) + var workInfo = workManager.getWorkInfosForUniqueWork(PERIODIC_WORK_TAG).await() + + assertTrue(workInfo.isEmpty()) + + updater.startPeriodicWork() + + workInfo = workManager.getWorkInfosForUniqueWork(PERIODIC_WORK_TAG).await() + + assertEquals(1, workInfo.size) + + updater.stopPeriodicWork() + + workInfo = workManager.getWorkInfosForUniqueWork(PERIODIC_WORK_TAG).await() + val work = workInfo.first() + + assertNull(ContileTopSitesUseCases.provider) + assertEquals(WorkInfo.State.CANCELLED, work.state) + } + + @Test + fun `WHEN period work request is created THEN it contains the correct constraints`() { + val updater = ContileTopSitesUpdater(testContext, provider = mock()) + val workRequest = updater.createPeriodicWorkRequest() + + assertTrue(workRequest.tags.contains(PERIODIC_WORK_TAG)) + assertEquals(updater.getWorkerConstraints(), workRequest.workSpec.constraints) + } +} diff --git a/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt new file mode 100644 index 00000000000..9757ae50ddd --- /dev/null +++ b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.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.service.contile + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker +import androidx.work.await +import androidx.work.testing.TestListenableWorkerBuilder +import kotlinx.coroutines.runBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.spy +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class ContileTopSitesUpdaterWorkerTest { + + @After + fun cleanup() { + ContileTopSitesUseCases.destroy() + } + + @Test + fun `WHEN worker does successful work THEN return a success result`() = runBlocking { + val provider: ContileTopSitesProvider = mock() + val worker = spy( + TestListenableWorkerBuilder(testContext) + .build() + ) + + ContileTopSitesUseCases.initialize(provider) + + whenever(provider.getTopSites(anyBoolean())).thenReturn(emptyList()) + + val result = worker.startWork().await() + + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun `WHEN worker does unsuccessful work THEN return a failure result`() = runBlocking { + val provider: ContileTopSitesProvider = mock() + val worker = spy( + TestListenableWorkerBuilder(testContext) + .build() + ) + val throwable = IOException("test") + + ContileTopSitesUseCases.initialize(provider) + + whenever(provider.getTopSites(anyBoolean())).then { + throw throwable + } + + val result = worker.startWork().await() + + assertEquals(ListenableWorker.Result.failure(), result) + } +} diff --git a/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt new file mode 100644 index 00000000000..bab461359e4 --- /dev/null +++ b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt @@ -0,0 +1,49 @@ +/* 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mockito.verify +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class ContileTopSitesUseCasesTest { + + @Test + fun `WHEN refresh contile top site use case is called THEN call the provider to fetch top sites bypassing the cache`() = runBlocking { + val provider: ContileTopSitesProvider = mock() + + ContileTopSitesUseCases.initialize(provider) + + whenever(provider.getTopSites(anyBoolean())).thenReturn(emptyList()) + + ContileTopSitesUseCases().refreshContileTopSites.invoke() + + verify(provider).getTopSites(eq(false)) + + Unit + } + + @Test(expected = IOException::class) + fun `GIVEN the provider fails to fetch contile top sites WHEN refresh contile top site use case is called THEN an exception is thrown`() = runBlocking { + val provider: ContileTopSitesProvider = mock() + val throwable = IOException("test") + + ContileTopSitesUseCases.initialize(provider) + + whenever(provider.getTopSites(anyBoolean())).then { + throw throwable + } + + ContileTopSitesUseCases().refreshContileTopSites.invoke() + } +}