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 11a1a1fe852..f871a20eff4 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
@@ -63,7 +63,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.Contile) {
+ historyStorage.deleteVisitsFor(topSite.url)
+ }
notifyObservers { onStorageUpdated() }
}
@@ -79,6 +81,7 @@ class DefaultTopSitesStorage(
}
}
+ @Suppress("TooGenericExceptionCaught")
override suspend fun getTopSites(
totalSites: Int,
frecencyConfig: FrecencyThresholdOption?
@@ -90,9 +93,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) {
+ // no-op
+ }
}
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..d4fcc0692b1 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 Contile(
+ 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..5e9570bcab7
--- /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.Contile? {
+ return try {
+ TopSite.Contile(
+ 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 714133e7ac9..1b4036809e4 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -14,6 +14,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