diff --git a/.buildconfig.yml b/.buildconfig.yml index d900b510c36..8f703fac4ec 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -303,6 +303,10 @@ projects: path: components/service/pocket description: 'A library to communicate with the Pocket API' publish: true + service-contile: + path: components/service/contile + description: 'A library to communicate with the Contile services API' + publish: true support-base: path: components/support/base description: 'Base component containing building blocks for components.' diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt index 923660f5d89..b6bdcfab177 100644 --- a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/DefaultTopSitesStorage.kt @@ -12,6 +12,7 @@ import mozilla.components.concept.storage.FrecencyThresholdOption 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 @@ -36,6 +37,7 @@ class DefaultTopSitesStorage( ) : TopSitesStorage, Observable by ObserverRegistry() { private var scope = CoroutineScope(coroutineContext) + private val logger = Logger("DefaultTopSitesStorage") // Cache of the last retrieved top sites var cachedTopSites = listOf() @@ -63,7 +65,9 @@ class DefaultTopSitesStorage( // 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. - historyStorage.deleteVisitsFor(topSite.url) + if (topSite !is TopSite.Provided) { + historyStorage.deleteVisitsFor(topSite.url) + } notifyObservers { onStorageUpdated() } } @@ -79,6 +83,7 @@ class DefaultTopSitesStorage( } } + @Suppress("TooGenericExceptionCaught") override suspend fun getTopSites( totalSites: Int, frecencyConfig: FrecencyThresholdOption? @@ -90,9 +95,13 @@ class DefaultTopSitesStorage( topSites.addAll(pinnedSites) topSitesProvider?.let { provider -> - val providerTopSites = provider.getTopSites() - topSites.addAll(providerTopSites.take(numSitesRequired)) - numSitesRequired -= providerTopSites.size + try { + val providerTopSites = provider.getTopSites() + topSites.addAll(providerTopSites.take(numSitesRequired)) + numSitesRequired -= providerTopSites.size + } catch (e: Exception) { + logger.error("Failed to fetch top sites from provider", e) + } } if (frecencyConfig != null && numSitesRequired > 0) { diff --git a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt index f04a9267b71..3d8a733ffe6 100644 --- a/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt +++ b/components/feature/top-sites/src/main/java/mozilla/components/feature/top/sites/TopSite.kt @@ -57,4 +57,27 @@ sealed class TopSite { override val url: String, override val createdAt: Long?, ) : 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 position The position of the top site. + * @property createdAt The optional date the top site was added. + */ + data class Provided( + override val id: Long?, + override val title: String?, + override val url: String, + val clickUrl: String, + val imageUrl: String, + val impressionUrl: String, + val position: Int, + override val createdAt: Long?, + ) : TopSite() } diff --git a/components/service/contile/README.md b/components/service/contile/README.md new file mode 100644 index 00000000000..c53b90af053 --- /dev/null +++ b/components/service/contile/README.md @@ -0,0 +1,20 @@ +# [Android Components](../../../README.md) > Service > Contile + +A library for communicating with the Contile services API. + +## 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:service-contile:{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/components/service/contile/build.gradle b/components/service/contile/build.gradle new file mode 100644 index 00000000000..fdbd3746a16 --- /dev/null +++ b/components/service/contile/build.gradle @@ -0,0 +1,42 @@ +/* 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' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation Dependencies.kotlin_stdlib + implementation Dependencies.kotlin_coroutines + + implementation project(':concept-fetch') + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':feature-top-sites') + + testImplementation Dependencies.androidx_test_core + testImplementation Dependencies.androidx_test_junit + testImplementation Dependencies.testing_robolectric + testImplementation Dependencies.testing_mockito + + testImplementation project(':support-test') +} + +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/service/contile/proguard-rules.pro b/components/service/contile/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/components/service/contile/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/components/service/contile/src/main/AndroidManifest.xml b/components/service/contile/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..17ba29a27f5 --- /dev/null +++ b/components/service/contile/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + 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 new file mode 100644 index 00000000000..78aba63fd32 --- /dev/null +++ b/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt @@ -0,0 +1,84 @@ +/* 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 mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.isSuccess +import mozilla.components.feature.top.sites.TopSite +import mozilla.components.feature.top.sites.TopSitesProvider +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.org.json.asSequence +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException + +internal const val CONTILE_ENDPOINT_URL = "https://contile.services.mozilla.com/v1/tiles" + +/** + * Provide access to the Contile services API. + * + * @property client [Client] used for interacting with the Contile HTTP API. + */ +class ContileTopSitesProvider( + private val client: Client +) : TopSitesProvider { + + private val logger = Logger("ContileTopSitesProvider") + + @Throws(IOException::class) + override suspend fun getTopSites(): List { + return try { + fetchTopSites() + } catch (e: IOException) { + logger.error("Failed to fetch contile top sites", e) + throw e + } + } + + private fun fetchTopSites(): List { + client.fetch( + Request(url = CONTILE_ENDPOINT_URL) + ).use { response -> + if (response.isSuccess) { + val responseBody = response.body.string(Charsets.UTF_8) + + return try { + JSONObject(responseBody).getTopSites() + } catch (e: JSONException) { + throw IOException(e) + } + } else { + val errorMessage = + "Failed to fetch contile top sites. Status code: ${response.status}" + logger.error(errorMessage) + throw IOException(errorMessage) + } + } + } +} + +internal fun JSONObject.getTopSites(): List = + getJSONArray("tiles") + .asSequence { i -> getJSONObject(i) } + .mapNotNull { it.toTopSite() } + .toList() + +private fun JSONObject.toTopSite(): TopSite.Provided? { + return try { + TopSite.Provided( + id = getLong("id"), + title = getString("name"), + url = getString("url"), + clickUrl = getString("click_url"), + imageUrl = getString("image_url"), + impressionUrl = getString("impression_url"), + position = getInt("position"), + createdAt = null + ) + } catch (e: JSONException) { + null + } +} diff --git a/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt new file mode 100644 index 00000000000..ac5e76b2229 --- /dev/null +++ b/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt @@ -0,0 +1,74 @@ +/* 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.concept.fetch.Client +import mozilla.components.concept.fetch.Response +import mozilla.components.support.test.any +import mozilla.components.support.test.file.loadResourceAsString +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class ContileTopSitesProviderTest { + + @Test + fun `GIVEN a successful status response WHEN getTopSites is called THEN response should contain top sites`() = runBlocking { + val client = prepareClient() + val provider = ContileTopSitesProvider(client) + val topSites = provider.getTopSites() + var topSite = topSites.first() + + assertEquals(2, topSites.size) + + assertEquals(1L, topSite.id) + assertEquals("Firefox", topSite.title) + assertEquals("https://firefox.com", topSite.url) + assertEquals("https://firefox.com/click", topSite.clickUrl) + assertEquals("https://test.com/image1.jpg", topSite.imageUrl) + assertEquals("https://test.com", topSite.impressionUrl) + assertEquals(1, topSite.position) + + topSite = topSites.last() + + assertEquals(2L, topSite.id) + assertEquals("Mozilla", topSite.title) + assertEquals("https://mozilla.com", topSite.url) + assertEquals("https://mozilla.com/click", topSite.clickUrl) + assertEquals("https://test.com/image2.jpg", topSite.imageUrl) + assertEquals("https://example.com", topSite.impressionUrl) + assertEquals(2, topSite.position) + } + + @Test(expected = IOException::class) + fun `GIVEN a 500 status response WHEN getTopSites is called THEN throw an exception`() = runBlocking { + val client = prepareClient(status = 500) + val provider = ContileTopSitesProvider(client) + provider.getTopSites() + Unit + } + + private fun prepareClient( + jsonResponse: String = loadResourceAsString("/contile/contile.json"), + status: Int = 200 + ): Client { + val mockedClient = mock() + val mockedResponse = mock() + val mockedBody = mock() + + whenever(mockedBody.string(any())).thenReturn(jsonResponse) + whenever(mockedResponse.body).thenReturn(mockedBody) + whenever(mockedResponse.status).thenReturn(status) + whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + + return mockedClient + } +} diff --git a/components/service/contile/src/test/resources/contile/contile.json b/components/service/contile/src/test/resources/contile/contile.json new file mode 100644 index 00000000000..7668717e104 --- /dev/null +++ b/components/service/contile/src/test/resources/contile/contile.json @@ -0,0 +1,24 @@ +{ + "tiles": [ + { + "id": 1, + "name": "Firefox", + "url": "https://firefox.com", + "click_url": "https://firefox.com/click", + "image_url": "https://test.com/image1.jpg", + "image_size": 200, + "impression_url": "https://test.com", + "position": 1 + }, + { + "id": 2, + "name": "Mozilla", + "url": "https://mozilla.com", + "click_url": "https://mozilla.com/click", + "image_url": "https://test.com/image2.jpg", + "image_size": 200, + "impression_url": "https://example.com", + "position": 2 + } + ] +} diff --git a/components/service/contile/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/service/contile/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/service/contile/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/components/service/contile/src/test/resources/robolectric.properties b/components/service/contile/src/test/resources/robolectric.properties new file mode 100644 index 00000000000..89a6c8b4c2e --- /dev/null +++ b/components/service/contile/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index c7a489b767d..e73931dfa78 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,9 @@ permalink: /changelog/ * **feature-top-sites** * ⚠️ **This is a breaking change**: The existing data class `TopSite` has been converted into a sealed class. [#11483](https://github.com/mozilla-mobile/android-components/issues/11483) * Extend `DefaultTopSitesStorage` to accept a `TopSitesProvider` for fetching top sites. [#11483](https://github.com/mozilla-mobile/android-components/issues/11483) + +* **service-contile** + * Adds a `ContileTopSitesProvider` that implements `TopSitesProvider` for returning top sites from the Contile services API. [#11483](https://github.com/mozilla-mobile/android-components/issues/11483) # 97.0.0 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v96.0.0...v97.0.0) diff --git a/docs/components.md b/docs/components.md index 9cff87f857e..4e96c498940 100644 --- a/docs/components.md +++ b/docs/components.md @@ -49,6 +49,7 @@ Independent, small visual UI elements to use in applications. ### Services +* [service-contile](https://github.com/mozilla-mobile/android-components/tree/main/components/service/contile) - A library for communicating with the Contile services API. * [service-firefox-accounts](https://github.com/mozilla-mobile/android-components/tree/main/components/service/firefox-accounts) - A library for integrating with [Firefox Accounts](https://mozilla.github.io/application-services/docs/accounts/welcome.html). * [service-fretboard](https://github.com/mozilla-mobile/android-components/tree/main/components/service/fretboard) - An Android framework for segmenting users in order to run A/B tests and rollout features gradually. * [service-pocket](https://github.com/mozilla-mobile/android-components/tree/main/components/service/pocket) - A library for communicating with the Pocket API. diff --git a/taskcluster/ci/config.yml b/taskcluster/ci/config.yml index f8b8bcf46ea..7d2d7e18170 100644 --- a/taskcluster/ci/config.yml +++ b/taskcluster/ci/config.yml @@ -84,6 +84,7 @@ treeherder: samples-sync-logins: samples-sync-logins samples-sync: samples-sync samples-toolbar: samples-toolbar + service-contile: service-contile service-digitalassetlinks: service-digitalassetlinks service-experiments: service-experiments service-firefox-accounts: service-firefox-accounts