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

Commit

Permalink
For #11483 - Part 3: Implement a ContileTopSitesProvider
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielluong authored and mergify[bot] committed Jan 17, 2022
1 parent 7c42975 commit a0acc00
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 4 deletions.
4 changes: 4 additions & 0 deletions .buildconfig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +37,7 @@ class DefaultTopSitesStorage(
) : TopSitesStorage, Observable<TopSitesStorage.Observer> by ObserverRegistry() {

private var scope = CoroutineScope(coroutineContext)
private val logger = Logger("DefaultTopSitesStorage")

// Cache of the last retrieved top sites
var cachedTopSites = listOf<TopSite>()
Expand Down Expand Up @@ -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() }
}
Expand All @@ -79,6 +83,7 @@ class DefaultTopSitesStorage(
}
}

@Suppress("TooGenericExceptionCaught")
override suspend fun getTopSites(
totalSites: Int,
frecencyConfig: FrecencyThresholdOption?
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
20 changes: 20 additions & 0 deletions components/service/contile/README.md
Original file line number Diff line number Diff line change
@@ -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/
42 changes: 42 additions & 0 deletions components/service/contile/build.gradle
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions components/service/contile/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions components/service/contile/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- 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/. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="mozilla.components.service.contile" >
</manifest>
Original file line number Diff line number Diff line change
@@ -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<TopSite.Provided> {
return try {
fetchTopSites()
} catch (e: IOException) {
logger.error("Failed to fetch contile top sites", e)
throw e
}
}

private fun fetchTopSites(): List<TopSite.Provided> {
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<TopSite.Provided> =
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
}
}
Original file line number Diff line number Diff line change
@@ -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<Client>()
val mockedResponse = mock<Response>()
val mockedBody = mock<Response.Body>()

whenever(mockedBody.string(any())).thenReturn(jsonResponse)
whenever(mockedResponse.body).thenReturn(mockedBody)
whenever(mockedResponse.status).thenReturn(status)
whenever(mockedClient.fetch(any())).thenReturn(mockedResponse)

return mockedClient
}
}
24 changes: 24 additions & 0 deletions components/service/contile/src/test/resources/contile/contile.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mock-maker-inline
// This allows mocking final classes (classes are final by default in Kotlin)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sdk=28
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit a0acc00

Please sign in to comment.