summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/service/location
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/service/location
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/service/location')
-rw-r--r--mobile/android/android-components/components/service/location/.gitignore1
-rw-r--r--mobile/android/android-components/components/service/location/README.md19
-rw-r--r--mobile/android/android-components/components/service/location/build.gradle44
-rw-r--r--mobile/android/android-components/components/service/location/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/service/location/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/LocationService.kt57
-rw-r--r--mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/MozillaLocationService.kt187
-rw-r--r--mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/LocationServiceTest.kt23
-rw-r--r--mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/MozillaLocationServiceTest.kt399
-rw-r--r--mobile/android/android-components/components/service/location/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker1
-rw-r--r--mobile/android/android-components/components/service/location/src/test/resources/robolectric.properties1
11 files changed, 757 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/location/.gitignore b/mobile/android/android-components/components/service/location/.gitignore
new file mode 100644
index 0000000000..796b96d1c4
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/mobile/android/android-components/components/service/location/README.md b/mobile/android/android-components/components/service/location/README.md
new file mode 100644
index 0000000000..9b333a44f9
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Service > Location
+
+ A library for accessing Mozilla's and other location services.
+
+## 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-location:{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/mobile/android/android-components/components/service/location/build.gradle b/mobile/android/android-components/components/service/location/build.gradle
new file mode 100644
index 0000000000..e9446bf656
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/build.gradle
@@ -0,0 +1,44 @@
+/* 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 {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.service.location'
+}
+
+dependencies {
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation project(':concept-fetch')
+ implementation project(':support-ktx')
+ implementation project(':support-ktx')
+ implementation project(':support-base')
+
+ testImplementation project(':support-test')
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_mockwebserver
+ testImplementation ComponentsDependencies.testing_coroutines
+
+ testImplementation project(':lib-fetch-httpurlconnection')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/service/location/proguard-rules.pro b/mobile/android/android-components/components/service/location/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/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/mobile/android/android-components/components/service/location/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/location/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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 />
diff --git a/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/LocationService.kt b/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/LocationService.kt
new file mode 100644
index 0000000000..8838b51bd1
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/LocationService.kt
@@ -0,0 +1,57 @@
+/* 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.location
+
+/**
+ * Interface describing a [LocationService] that returns a [Region].
+ */
+interface LocationService {
+ /**
+ * Determines the current [Region] of the user.
+ */
+ suspend fun fetchRegion(readFromCache: Boolean = true): Region?
+
+ /**
+ * Get if there is already a cached region.
+ */
+ fun hasRegionCached(): Boolean
+
+ /**
+ * A [Region] returned by the location service.
+ *
+ * The [Region] use region codes and names from the GENC dataset, which is for the most part
+ * compatible with the ISO 3166 standard. While the API endpoint and [Region] class refers to
+ * country, no claim about the political status of any region is made by this service.
+ *
+ * @param countryCode Country code; ISO 3166.
+ * @param countryName Name of the country (English); ISO 3166.
+ */
+ data class Region(
+ val countryCode: String,
+ val countryName: String,
+ )
+
+ companion object {
+ /**
+ * Creates a dummy [LocationService] implementation that always returns `null`.
+ */
+ fun dummy() = object : LocationService {
+ override suspend fun fetchRegion(readFromCache: Boolean): Region? = null
+ override fun hasRegionCached(): Boolean = false
+ }
+
+ /**
+ * Creates a default [LocationService] implementation that always returns the "XX" region.
+ *
+ * The advantage of using the default implementation over the dummy implementations is that
+ * code may stop retrying fetching a region if a region was returned from the service
+ * instead of `null` which indicates a failure.
+ */
+ fun default() = object : LocationService {
+ override suspend fun fetchRegion(readFromCache: Boolean): Region? = Region("XX", "None")
+ override fun hasRegionCached(): Boolean = true
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/MozillaLocationService.kt b/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/MozillaLocationService.kt
new file mode 100644
index 0000000000..663ec7c5fe
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/main/java/mozilla/components/service/location/MozillaLocationService.kt
@@ -0,0 +1,187 @@
+/* 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.location
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.NONE
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import mozilla.components.Build
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Headers
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.concept.fetch.isSuccess
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.ktx.kotlin.sanitizeURL
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+private const val GEOIP_SERVICE_URL = "https://location.services.mozilla.com/v1/"
+private const val CONNECT_TIMEOUT_SECONDS = 10L
+private const val READ_TIMEOUT_SECONDS = 10L
+private const val USER_AGENT = "MozAC/" + Build.version
+private const val EMPTY_REQUEST_BODY = "{}"
+private const val CACHE_FILE = "mozac.service.location.region"
+private const val KEY_COUNTRY_CODE = "country_code"
+private const val KEY_COUNTRY_NAME = "country_name"
+private const val KEY_CACHED_AT = "cached_at"
+
+// The amount of time (in seconds) to cache the result of `MozillaLocationService.fetchRegion()`.
+private const val CACHE_LIFETIME_IN_MS = 24 * 60 * 60 * 1000
+
+/**
+ * The Mozilla Location Service (MLS) is an open service which lets devices determine their location
+ * based on network infrastructure like Bluetooth beacons, cell towers and WiFi access points.
+ *
+ * - https://location.services.mozilla.com/
+ * - https://mozilla.github.io/ichnaea/api/index.html
+ *
+ * Note: Accessing the Mozilla Location Service requires an API token:
+ * https://location.services.mozilla.com/contact
+ *
+ * @param client The HTTP client that this [MozillaLocationService] should use for requests.
+ * @param apiKey The API key that is used to access the Mozilla location service.
+ * @param serviceUrl An optional URL override usually only needed for testing.
+ */
+class MozillaLocationService(
+ private val context: Context,
+ private val client: Client,
+ apiKey: String,
+ serviceUrl: String = GEOIP_SERVICE_URL,
+ private val currentTime: () -> Long = { System.currentTimeMillis() },
+) : LocationService {
+ private val regionServiceUrl = (serviceUrl + "country?key=%s").format(apiKey)
+
+ /**
+ * Determines the current [LocationService.Region] based on the IP address used to access the service.
+ *
+ * https://mozilla.github.io/ichnaea/api/region.html
+ *
+ * @param readFromCache Whether a previously returned region (from the cache) can be returned
+ * (default) or whether a request to the service should always be made.
+ */
+ override suspend fun fetchRegion(
+ readFromCache: Boolean,
+ ): LocationService.Region? = withContext(Dispatchers.IO) {
+ if (readFromCache && isCacheValid()) {
+ context.loadCachedRegion()?.let { return@withContext it }
+ }
+
+ client.fetchRegion(regionServiceUrl)?.also {
+ context.cacheRegion(it)
+ }
+ }
+
+ /**
+ * Get if there is already a cached region.
+ * This does not guarantee we have the current actual region but only the last value
+ * which may be obsolete at this time.
+ */
+ override fun hasRegionCached(): Boolean {
+ return context.hasCachedRegion()
+ }
+
+ /**
+ * Check to see if the cache is still valid.
+ */
+ private fun isCacheValid(): Boolean {
+ return currentTime() < context.cachedAt() + CACHE_LIFETIME_IN_MS
+ }
+
+ private fun Context.cacheRegion(region: LocationService.Region) {
+ regionCache()
+ .edit()
+ .putString(KEY_COUNTRY_CODE, region.countryCode)
+ .putString(KEY_COUNTRY_NAME, region.countryName)
+ .putLong(KEY_CACHED_AT, currentTime())
+ .apply()
+ }
+}
+
+private fun Context.loadCachedRegion(): LocationService.Region? {
+ val cache = regionCache()
+
+ return if (cache.contains(KEY_COUNTRY_CODE) && cache.contains(KEY_COUNTRY_NAME)) {
+ LocationService.Region(
+ cache.getString(KEY_COUNTRY_CODE, null)!!,
+ cache.getString(KEY_COUNTRY_NAME, null)!!,
+ )
+ } else {
+ null
+ }
+}
+
+private fun Context.cachedAt(): Long {
+ val cache = regionCache()
+ return cache.getLong(KEY_CACHED_AT, 0L)
+}
+
+private fun Context.hasCachedRegion(): Boolean {
+ val cache = regionCache()
+ return cache.contains(KEY_COUNTRY_CODE) && cache.contains(KEY_COUNTRY_NAME)
+}
+
+@VisibleForTesting(otherwise = NONE)
+internal fun Context.clearRegionCache() {
+ regionCache()
+ .edit()
+ .clear()
+ .apply()
+}
+
+private fun Context.regionCache(): SharedPreferences {
+ return getSharedPreferences(CACHE_FILE, Context.MODE_PRIVATE)
+}
+
+private fun Client.fetchRegion(regionServiceUrl: String): LocationService.Region? {
+ val request = Request(
+ url = regionServiceUrl.sanitizeURL(),
+ method = Request.Method.POST,
+ headers = MutableHeaders(
+ Headers.Names.CONTENT_TYPE to Headers.Values.CONTENT_TYPE_APPLICATION_JSON,
+ Headers.Names.USER_AGENT to USER_AGENT,
+ ),
+ // We are posting an empty request body here. This means the service will only use the IP
+ // address to provide a region response. Technically it's possible to also provide data
+ // about nearby Bluetooth, cell or WiFi networks.
+ body = Request.Body.fromString(EMPTY_REQUEST_BODY),
+ connectTimeout = Pair(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ readTimeout = Pair(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ conservative = true,
+ )
+
+ return try {
+ fetch(request).toRegion()
+ } catch (e: IOException) {
+ Logger.debug(message = "Could not fetch region from location service", throwable = e)
+ null
+ }
+}
+
+private fun Response.toRegion(): LocationService.Region? {
+ if (!isSuccess) {
+ close()
+ return null
+ }
+
+ use {
+ return try {
+ val json = JSONObject(body.string(Charsets.UTF_8))
+ LocationService.Region(
+ json.getString(KEY_COUNTRY_CODE),
+ json.getString(KEY_COUNTRY_NAME),
+ )
+ } catch (e: JSONException) {
+ Logger.debug(message = "Could not parse JSON returned from location service", throwable = e)
+ null
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/LocationServiceTest.kt b/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/LocationServiceTest.kt
new file mode 100644
index 0000000000..eca31d268f
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/LocationServiceTest.kt
@@ -0,0 +1,23 @@
+/* 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.location
+
+import junit.framework.TestCase.assertNull
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LocationServiceTest {
+ @ExperimentalCoroutinesApi
+ @Test
+ fun `dummy implementation returns null`() {
+ runTest(UnconfinedTestDispatcher()) {
+ assertNull(LocationService.dummy().fetchRegion(false))
+ assertNull(LocationService.dummy().fetchRegion(true))
+ assertNull(LocationService.dummy().fetchRegion(false))
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/MozillaLocationServiceTest.kt b/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/MozillaLocationServiceTest.kt
new file mode 100644
index 0000000000..d2cf7d64aa
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/test/java/mozilla/components/service/location/MozillaLocationServiceTest.kt
@@ -0,0 +1,399 @@
+/* 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.location
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.currentTime
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.fakes.FakeClock
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.doThrow
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import java.io.IOException
+
+@ExperimentalCoroutinesApi // for runTest
+@RunWith(AndroidJUnit4::class)
+class MozillaLocationServiceTest {
+ @Before
+ @After
+ fun cleanUp() {
+ testContext.clearRegionCache()
+ }
+
+ @Test
+ fun `WHEN calling fetchRegion AND the service returns a region THEN a Region object is returned`() = runTest {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setBody("{\"country_name\": \"Germany\", \"country_code\": \"DE\"}"))
+
+ try {
+ server.start()
+
+ val service = MozillaLocationService(
+ testContext,
+ HttpURLConnectionClient(),
+ apiKey = "test",
+ serviceUrl = server.url("/").toString(),
+ )
+
+ val region = service.fetchRegion()
+
+ assertNotNull(region!!)
+
+ assertEquals("DE", region.countryCode)
+ assertEquals("Germany", region.countryName)
+
+ val request = server.takeRequest()
+
+ assertEquals(server.url("/country?key=test"), request.requestUrl)
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun `WHEN client throws IOException THEN the returned region is null`() = runTest {
+ val client: Client = mock()
+ doThrow(IOException()).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region THEN request is sent to the location service`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"France\", \"country_code\": \"FR\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNotNull(region!!)
+
+ assertEquals("FR", region.countryCode)
+ assertEquals("France", region.countryName)
+
+ val captor = argumentCaptor<Request>()
+ verify(client).fetch(captor.capture())
+
+ val request = captor.value
+ assertEquals("https://location.services.mozilla.com/v1/country?key=test", request.url)
+ }
+
+ @Test
+ fun `WHEN fetching region AND service returns 404 THEN region is null`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 404,
+ headers = MutableHeaders(),
+ body = Response.Body.empty(),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region AND service returns 500 THEN region is null`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 500,
+ headers = MutableHeaders(),
+ body = Response.Body("Internal Server Error".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region AND service returns broken JSON THEN region is null`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"France\",".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region AND service returns empty JSON object THEN region is null`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region AND service returns incomplete JSON THEN region is null`() = runTest {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_code\": \"DE\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNull(region)
+ }
+
+ @Test
+ fun `WHEN fetching region for the second time THEN region is read from cache`() = runTest {
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Nepal\", \"country_code\": \"NP\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client).fetch(any())
+ }
+
+ run {
+ val client: Client = mock()
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client, never()).fetch(any())
+ }
+ }
+
+ @Test
+ fun `WHEN fetching region for the second time and setting readFromCache = false THEN request is sent again`() = runTest {
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Nepal\", \"country_code\": \"NP\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion()
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client).fetch(any())
+ }
+
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Liberia\", \"country_code\": \"LR\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(testContext, client, apiKey = "test")
+ val region = service.fetchRegion(readFromCache = false)
+
+ assertNotNull(region!!)
+
+ assertEquals("LR", region.countryCode)
+ assertEquals("Liberia", region.countryName)
+
+ verify(client).fetch(any())
+ }
+ }
+
+ @Test
+ fun `WHEN fetching region and the cache is valid THEN request is not sent again`() = runTest {
+ val clock = FakeClock()
+
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Nepal\", \"country_code\": \"NP\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(
+ testContext,
+ client,
+ apiKey = "test",
+ currentTime = clock::time,
+ )
+ val region = service.fetchRegion(readFromCache = true)
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client).fetch(any())
+ }
+
+ // Let's jump 23 hours in the future
+ clock.advanceBy(23 * 60 * 60 * 1000)
+
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Liberia\", \"country_code\": \"LR\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(
+ testContext,
+ client,
+ apiKey = "test",
+ currentTime = clock::time,
+ )
+ val region = service.fetchRegion(readFromCache = true)
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client, never()).fetch(any())
+ }
+ }
+
+ @Test
+ fun `WHEN fetching region and the cache is invalid THEN request is sent again`() = runTest {
+ val clock = FakeClock()
+
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Nepal\", \"country_code\": \"NP\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(
+ testContext,
+ client,
+ apiKey = "test",
+ currentTime = clock::time,
+ )
+ val region = service.fetchRegion(readFromCache = true)
+
+ assertNotNull(region!!)
+
+ assertEquals("NP", region.countryCode)
+ assertEquals("Nepal", region.countryName)
+
+ verify(client).fetch(any())
+ }
+
+ // Let's jump 24 hours in the future
+ clock.advanceBy(24 * 60 * 60 * 1000)
+
+ run {
+ val client: Client = mock()
+ val response = Response(
+ url = "http://example.org",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body("{\"country_name\": \"Liberia\", \"country_code\": \"LR\"}".byteInputStream()),
+ )
+ doReturn(response).`when`(client).fetch(any())
+
+ val service = MozillaLocationService(
+ testContext,
+ client,
+ apiKey = "test",
+ currentTime = clock::time,
+ )
+ val region = service.fetchRegion(readFromCache = true)
+
+ assertNotNull(region!!)
+
+ assertEquals("LR", region.countryCode)
+ assertEquals("Liberia", region.countryName)
+
+ verify(client).fetch(any())
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/location/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/location/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..1f0955d450
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/mobile/android/android-components/components/service/location/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/location/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/service/location/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28